fciannella commited on
Commit
59ba41d
·
1 Parent(s): b7f4c65

Added the nurse agent

Browse files
Files changed (19) hide show
  1. examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.1.pckl +0 -0
  2. examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.2.pckl +0 -0
  3. examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.3.pckl +0 -0
  4. examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_ops.pckl +0 -0
  5. examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_retry_counter.pckl +0 -0
  6. examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/store.pckl +0 -0
  7. examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/store.vectors.pckl +0 -0
  8. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/README.md +51 -0
  9. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/__init__.py +9 -0
  10. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/logic.py +622 -0
  11. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/patients.json +110 -0
  12. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/pharmacies.json +16 -0
  13. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/providers.json +26 -0
  14. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/triage_rules.json +54 -0
  15. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/prompts.py +31 -0
  16. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/react_agent.py +394 -0
  17. examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/tools.py +167 -0
  18. examples/voice_agent_webrtc_langgraph/agents/langgraph.json +2 -1
  19. examples/voice_agent_webrtc_langgraph/pipeline.py +1 -0
examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.1.pckl ADDED
Binary file (6 Bytes). View file
 
examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.2.pckl ADDED
Binary file (6 Bytes). View file
 
examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.3.pckl ADDED
Binary file (6 Bytes). View file
 
examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_ops.pckl ADDED
Binary file (929 Bytes). View file
 
examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_retry_counter.pckl ADDED
Binary file (6 Bytes). View file
 
examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/store.pckl ADDED
Binary file (6 Bytes). View file
 
examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/store.vectors.pckl ADDED
Binary file (6 Bytes). View file
 
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/README.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Healthcare Telehealth Nurse Agent
2
+
3
+ This agent is a 24/7 AI voice nurse for existing patients. It authenticates callers, retrieves mock patient profiles (medications, allergies, recent visits), recognizes common symptoms, performs contextual triage using decision rules, offers self‑care guidance, escalates urgent cases, and books appointments. All interactions aim to be clear, compassionate, and brief.
4
+
5
+ ## What it does
6
+
7
+ - Authenticate the caller (name + DOB + MRN last‑4 or secret answer)
8
+ - Look up patient profile, allergies, medications, recent visits, preferred pharmacy
9
+ - Recognize symptoms (e.g., "I have a headache") and identify red flags
10
+ - Risk stratify and triage (self‑care, soon, urgent) using rules in `mock_data/triage_rules.json`
11
+ - Provide tailored advice based on symptoms and medication history
12
+ - Escalate urgent cases with clear instructions
13
+ - Offer scheduling with available providers from `mock_data/providers.json`
14
+ - Confirm preferred pharmacy for prescriptions if needed
15
+
16
+ ## How to use (typical flow)
17
+
18
+ 1. Start by stating your name.
19
+ 2. Verify identity: provide DOB and either MRN last‑4 or your secret answer. If a secret question is returned, answer it.
20
+ 3. Describe your symptoms in your own words.
21
+ 4. The agent will ask brief, focused follow‑ups (one question at a time), provide guidance, and book if needed.
22
+
23
+ ## Example conversation
24
+
25
+ - Agent: Welcome back to our clinic. I’m your nurse assistant. May I have your full name?
26
+ - You: John Marshall.
27
+ - Agent: Thanks, John. Please confirm your date of birth and either your MRN last‑4 or answer to your secret question.
28
+ - You: January 1st, 1960. MRN last‑4 is 0001.
29
+ - Agent: Thank you. You’re verified. What’s going on today?
30
+ - You: I have a headache and feel really fatigued.
31
+ - Agent: I’m sorry you’re feeling that way. Any red flag symptoms like worst headache of your life, head injury, stiff neck, weakness, vision changes, seizure, or fever over 102°F?
32
+ - You: No, none of those.
33
+ - Agent: Thanks for checking. You’re on acetaminophen as needed and have a penicillin allergy. Since symptoms are mild with no red flags, try hydration, rest, and acetaminophen as directed. Would you like a telehealth appointment in the next day or two?
34
+ - You: Yes, please.
35
+ - Agent: I can book with Alex Chang, NP. Next openings are today at 8:00 pm, or tomorrow at 8:30 am or 4:00 pm. Which do you prefer?
36
+ - You: Tomorrow at 8:30 am.
37
+ - Agent: Booked. I’ll send details to your phone ending in 0101. Should we keep the pharmacy at 1010 El Camino Real, Santa Clara for any prescriptions?
38
+ - You: Yes, that’s fine.
39
+ - Agent: Great. I’ve logged your symptoms, advice provided, and appointment. If anything worsens—especially fever over 102°F, neck stiffness, confusion, weakness, vision loss, or severe headache—seek urgent care or call emergency services.
40
+
41
+ ## Mock data
42
+
43
+ - Patients: `mock_data/patients.json`
44
+ - Providers: `mock_data/providers.json`
45
+ - Pharmacies: `mock_data/pharmacies.json`
46
+ - Triage rules: `mock_data/triage_rules.json`
47
+
48
+ ## Notes
49
+
50
+ - The agent prioritizes empathy and brevity. It asks one question at a time and avoids medical jargon.
51
+ - All data are mock fixtures for demo purposes only and are not real medical records.
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Wire Transfer Agent (ReAct)
2
+
3
+ This package contains a LangGraph ReAct-based agent that helps users
4
+ initiate domestic or international wire transfers using mock tools and fixtures.
5
+ """
6
+
7
+ from .react_agent import agent # noqa: F401
8
+
9
+
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/logic.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import uuid
4
+ from datetime import datetime
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from langchain_openai import ChatOpenAI
10
+
11
+
12
+ _FIXTURE_CACHE: Dict[str, Any] = {}
13
+ _DISPUTES_DB: Dict[str, Dict[str, Any]] = {}
14
+ _SESSIONS: Dict[str, Dict[str, Any]] = {}
15
+ _OTP_DB: Dict[str, Dict[str, Any]] = {}
16
+ _QUOTES: Dict[str, Dict[str, Any]] = {}
17
+ _BENEFICIARIES_DB: Dict[str, List[Dict[str, Any]]] = {}
18
+
19
+
20
+ def _fixtures_dir() -> Path:
21
+ return Path(__file__).parent / "mock_data"
22
+
23
+
24
+ def _load_fixture(name: str) -> Any:
25
+ if name in _FIXTURE_CACHE:
26
+ return _FIXTURE_CACHE[name]
27
+ p = _fixtures_dir() / name
28
+ with p.open("r", encoding="utf-8") as f:
29
+ data = json.load(f)
30
+ _FIXTURE_CACHE[name] = data
31
+ return data
32
+
33
+
34
+ def _parse_iso_date(text: Optional[str]) -> Optional[datetime]:
35
+ if not text:
36
+ return None
37
+ try:
38
+ return datetime.strptime(text, "%Y-%m-%d")
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def _get_customer_blob(customer_id: str) -> Dict[str, Any]:
44
+ data = _load_fixture("accounts.json")
45
+ return dict(data.get("customers", {}).get(customer_id, {}))
46
+
47
+
48
+ def get_accounts(customer_id: str) -> List[Dict[str, Any]]:
49
+ cust = _get_customer_blob(customer_id)
50
+ if isinstance(cust, list):
51
+ # backward-compat: old format was a list of accounts
52
+ return list(cust)
53
+ return list(cust.get("accounts", []))
54
+
55
+
56
+ def get_profile(customer_id: str) -> Dict[str, Any]:
57
+ cust = _get_customer_blob(customer_id)
58
+ if isinstance(cust, dict):
59
+ return dict(cust.get("profile", {}))
60
+ return {}
61
+
62
+
63
+ def find_customer_by_name(first_name: str, last_name: str) -> Dict[str, Any]:
64
+ data = _load_fixture("accounts.json")
65
+ customers = data.get("customers", {})
66
+ fn = (first_name or "").strip().lower()
67
+ ln = (last_name or "").strip().lower()
68
+ for cid, blob in customers.items():
69
+ prof = blob.get("profile") if isinstance(blob, dict) else None
70
+ if isinstance(prof, dict):
71
+ pfn = str(prof.get("first_name") or "").strip().lower()
72
+ pln = str(prof.get("last_name") or "").strip().lower()
73
+ if fn == pfn and ln == pln:
74
+ return {"customer_id": cid, "profile": prof}
75
+ return {}
76
+
77
+
78
+ def find_customer_by_full_name(full_name: str) -> Dict[str, Any]:
79
+ data = _load_fixture("accounts.json")
80
+ customers = data.get("customers", {})
81
+ target = (full_name or "").strip().lower()
82
+ for cid, blob in customers.items():
83
+ prof = blob.get("profile") if isinstance(blob, dict) else None
84
+ if isinstance(prof, dict):
85
+ fn = f"{str(prof.get('first_name') or '').strip()} {str(prof.get('last_name') or '').strip()}".strip().lower()
86
+ ff = str(prof.get("full_name") or "").strip().lower()
87
+ if target and (target == fn or target == ff):
88
+ return {"customer_id": cid, "profile": prof}
89
+ return {}
90
+
91
+
92
+ def _normalize_dob(text: Optional[str]) -> Optional[str]:
93
+ if not isinstance(text, str) or not text.strip():
94
+ return None
95
+ t = text.strip().lower()
96
+ # YYYY-MM-DD
97
+ try:
98
+ if len(t) >= 10 and t[4] == '-' and t[7] == '-':
99
+ d = datetime.strptime(t[:10], "%Y-%m-%d")
100
+ return d.strftime("%Y-%m-%d")
101
+ except Exception:
102
+ pass
103
+ # YYYY MM DD or YYYY/MM/DD or YYYY.MM.DD (loosely)
104
+ try:
105
+ import re as _re
106
+ parts = _re.findall(r"\d+", t)
107
+ if len(parts) >= 3 and len(parts[0]) == 4:
108
+ y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
109
+ if 1900 <= y <= 2100 and 1 <= m <= 12 and 1 <= d <= 31:
110
+ dt = datetime(y, m, d)
111
+ return dt.strftime("%Y-%m-%d")
112
+ except Exception:
113
+ pass
114
+ # Month name DD YYYY
115
+ MONTHS = {
116
+ "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
117
+ "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
118
+ "aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9,
119
+ "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
120
+ }
121
+ try:
122
+ parts = t.replace(',', ' ').split()
123
+ if len(parts) >= 3 and parts[0] in MONTHS:
124
+ m = MONTHS[parts[0]]
125
+ day = int(''.join(ch for ch in parts[1] if ch.isdigit()))
126
+ year = int(parts[2])
127
+ d = datetime(year, m, day)
128
+ return d.strftime("%Y-%m-%d")
129
+ except Exception:
130
+ pass
131
+ # DD/MM/YYYY or MM/DD/YYYY
132
+ try:
133
+ for sep in ('/', '-'):
134
+ if sep in t and t.count(sep) == 2:
135
+ a, b, c = t.split(sep)[:3]
136
+ if len(c) == 4 and a.isdigit() and b.isdigit() and c.isdigit():
137
+ da, db, dy = int(a), int(b), int(c)
138
+ # If first looks like month, assume MM/DD
139
+ if 1 <= da <= 12 and 1 <= db <= 31:
140
+ d = datetime(dy, da, db)
141
+ else:
142
+ # assume DD/MM
143
+ d = datetime(dy, db, da)
144
+ return d.strftime("%Y-%m-%d")
145
+ except Exception:
146
+ pass
147
+ return None
148
+
149
+
150
+ def _find_account_by_id(account_id: str) -> Optional[Dict[str, Any]]:
151
+ data = _load_fixture("accounts.json")
152
+ customers = data.get("customers", {})
153
+ for _, blob in customers.items():
154
+ accts = (blob or {}).get("accounts", [])
155
+ for a in accts or []:
156
+ if str(a.get("account_id")) == account_id:
157
+ return a
158
+ return None
159
+
160
+
161
+ def get_account_balance(account_id: str) -> Dict[str, Any]:
162
+ acc = _find_account_by_id(account_id) or {}
163
+ return {
164
+ "account_id": account_id,
165
+ "currency": acc.get("currency"),
166
+ "balance": float(acc.get("balance", 0.0)),
167
+ "daily_wire_limit": float(acc.get("daily_wire_limit", 0.0)),
168
+ "wire_enabled": bool(acc.get("wire_enabled", False)),
169
+ }
170
+
171
+
172
+ def get_exchange_rate(from_currency: str, to_currency: str, amount: float) -> Dict[str, Any]:
173
+ if from_currency.upper() == to_currency.upper():
174
+ return {
175
+ "from": from_currency.upper(),
176
+ "to": to_currency.upper(),
177
+ "mid_rate": 1.0,
178
+ "applied_rate": 1.0,
179
+ "margin_bps": 0,
180
+ "converted_amount": round(float(amount), 2),
181
+ }
182
+ data = _load_fixture("exchange_rates.json")
183
+ pairs = data.get("pairs", [])
184
+ mid = None
185
+ bps = 150
186
+ fc = from_currency.upper()
187
+ tc = to_currency.upper()
188
+ for p in pairs:
189
+ if str(p.get("from")).upper() == fc and str(p.get("to")).upper() == tc:
190
+ mid = float(p.get("mid_rate"))
191
+ bps = int(p.get("margin_bps", bps))
192
+ break
193
+ if mid is None:
194
+ # naive inverse lookup
195
+ for p in pairs:
196
+ if str(p.get("from")).upper() == tc and str(p.get("to")).upper() == fc:
197
+ inv = float(p.get("mid_rate"))
198
+ mid = 1.0 / inv if inv else None
199
+ bps = int(p.get("margin_bps", bps))
200
+ break
201
+ if mid is None:
202
+ mid = 1.0
203
+ applied = mid * (1.0 - bps / 10000.0)
204
+ converted = float(amount) * applied
205
+ return {
206
+ "from": fc,
207
+ "to": tc,
208
+ "mid_rate": round(mid, 6),
209
+ "applied_rate": round(applied, 6),
210
+ "margin_bps": bps,
211
+ "converted_amount": round(converted, 2),
212
+ }
213
+
214
+
215
+ def calculate_wire_fee(kind: str, amount: float, from_currency: str, to_currency: str, payer: str) -> Dict[str, Any]:
216
+ fees = _load_fixture("fee_schedules.json")
217
+ k = (kind or "").strip().upper()
218
+ payer_opt = (payer or "SHA").strip().upper()
219
+ if k not in ("DOMESTIC", "INTERNATIONAL"):
220
+ return {"error": "invalid_type", "message": "type must be DOMESTIC or INTERNATIONAL"}
221
+ if payer_opt not in ("OUR", "SHA", "BEN"):
222
+ return {"error": "invalid_payer", "message": "payer must be OUR, SHA, or BEN"}
223
+ breakdown: Dict[str, float] = {}
224
+ if k == "DOMESTIC":
225
+ breakdown["DOMESTIC_BASE"] = float(fees.get("DOMESTIC", {}).get("base_fee", 15.0))
226
+ else:
227
+ intl = fees.get("INTERNATIONAL", {})
228
+ breakdown["INTERNATIONAL_BASE"] = float(intl.get("base_fee", 25.0))
229
+ breakdown["SWIFT"] = float(intl.get("swift_network_fee", 5.0))
230
+ breakdown["CORRESPONDENT"] = float(intl.get("correspondent_fee", 10.0))
231
+ breakdown["LIFTING"] = float(intl.get("lifting_fee", 5.0))
232
+
233
+ initiator = 0.0
234
+ recipient = 0.0
235
+ for code, fee in breakdown.items():
236
+ if payer_opt == "OUR":
237
+ initiator += fee
238
+ elif payer_opt == "SHA":
239
+ # Sender pays origin bank fees (base, swift); recipient pays intermediary (correspondent/lifting)
240
+ if code in ("DOMESTIC_BASE", "INTERNATIONAL_BASE", "SWIFT"):
241
+ initiator += fee
242
+ else:
243
+ recipient += fee
244
+ elif payer_opt == "BEN":
245
+ recipient += fee
246
+ return {
247
+ "type": k,
248
+ "payer": payer_opt,
249
+ "from_currency": from_currency.upper(),
250
+ "to_currency": to_currency.upper(),
251
+ "amount": float(amount),
252
+ "initiator_fees_total": round(initiator, 2),
253
+ "recipient_fees_total": round(recipient, 2),
254
+ "breakdown": {k: round(v, 2) for k, v in breakdown.items()},
255
+ }
256
+
257
+
258
+ def screen_sanctions(name: str, country: str) -> Dict[str, Any]:
259
+ data = _load_fixture("sanctions_list.json")
260
+ blocked = data.get("blocked", [])
261
+ nm = (name or "").strip().lower()
262
+ cc = (country or "").strip().upper()
263
+ for e in blocked:
264
+ if str(e.get("name", "")).strip().lower() == nm and str(e.get("country", "")).strip().upper() == cc:
265
+ return {"cleared": False, "reason": "Sanctions match"}
266
+ return {"cleared": True}
267
+
268
+
269
+ def check_wire_limits(account_id: str, amount: float) -> Dict[str, Any]:
270
+ acc = _find_account_by_id(account_id) or {}
271
+ if not acc:
272
+ return {"ok": False, "reason": "account_not_found"}
273
+ bal = float(acc.get("balance", 0.0))
274
+ lim = float(acc.get("daily_wire_limit", 0.0))
275
+ if not bool(acc.get("wire_enabled", False)):
276
+ return {"ok": False, "reason": "wire_not_enabled"}
277
+ if amount > lim:
278
+ return {"ok": False, "reason": "exceeds_daily_limit", "limit": lim}
279
+ if amount > bal:
280
+ return {"ok": False, "reason": "insufficient_funds", "balance": bal}
281
+ return {"ok": True, "balance": bal, "limit": lim}
282
+
283
+
284
+ def get_cutoff_and_eta(kind: str, country: str) -> Dict[str, Any]:
285
+ cfg = _load_fixture("cutoff_times.json")
286
+ k = (kind or "").strip().upper()
287
+ key = "DOMESTIC" if k == "DOMESTIC" else "INTERNATIONAL"
288
+ info = cfg.get(key, {})
289
+ return {
290
+ "cutoff_local": info.get("cutoff_local", "17:00"),
291
+ "eta_hours": list(info.get("eta_hours", [24, 72])),
292
+ "country": country
293
+ }
294
+
295
+
296
+ def get_country_requirements(code: str) -> List[str]:
297
+ data = _load_fixture("country_requirements.json")
298
+ return list(data.get(code.upper(), []))
299
+
300
+
301
+ def validate_beneficiary(country_code: str, beneficiary: Dict[str, Any]) -> Dict[str, Any]:
302
+ required = get_country_requirements(country_code)
303
+ missing: List[str] = []
304
+ for field in required:
305
+ if not isinstance(beneficiary.get(field), str) or not str(beneficiary.get(field)).strip():
306
+ missing.append(field)
307
+ return {"ok": len(missing) == 0, "missing": missing}
308
+
309
+
310
+ def save_beneficiary(customer_id: str, beneficiary: Dict[str, Any]) -> Dict[str, Any]:
311
+ arr = _BENEFICIARIES_DB.setdefault(customer_id, [])
312
+ bid = beneficiary.get("beneficiary_id") or f"B-{uuid.uuid4().hex[:6]}"
313
+ entry = dict(beneficiary)
314
+ entry["beneficiary_id"] = bid
315
+ arr.append(entry)
316
+ return {"beneficiary_id": bid}
317
+
318
+
319
+ def generate_otp(customer_id: str) -> Dict[str, Any]:
320
+ # Prefer static OTP from fixture for predictable testing
321
+ static = None
322
+ try:
323
+ data = _load_fixture("otps.json")
324
+ if isinstance(data, dict):
325
+ byc = data.get("by_customer", {}) or {}
326
+ static = byc.get(customer_id) or data.get("default")
327
+ except Exception:
328
+ static = None
329
+ code = str(static or f"{uuid.uuid4().int % 1000000:06d}").zfill(6)
330
+ _OTP_DB[customer_id] = {"otp": code, "created_at": datetime.utcnow().isoformat() + "Z"}
331
+ # In real world, send to phone/email; here we mask
332
+ resp = {"sent": True, "destination": "on-file", "masked": "***-***-****"}
333
+ try:
334
+ if os.getenv("WIRE_DEBUG_OTP", "0").lower() not in ("", "0", "false"): # dev convenience
335
+ resp["debug_code"] = code
336
+ except Exception:
337
+ pass
338
+ return resp
339
+
340
+
341
+ def verify_otp(customer_id: str, otp: str) -> Dict[str, Any]:
342
+ rec = _OTP_DB.get(customer_id) or {}
343
+ ok = str(rec.get("otp")) == str(otp)
344
+ if ok:
345
+ rec["used_at"] = datetime.utcnow().isoformat() + "Z"
346
+ _OTP_DB[customer_id] = rec
347
+ return {"verified": ok}
348
+
349
+
350
+ def authenticate_user_wire(session_id: str, customer_id: Optional[str], full_name: Optional[str], dob_yyyy_mm_dd: Optional[str], ssn_last4: Optional[str], secret_answer: Optional[str]) -> Dict[str, Any]:
351
+ session = _SESSIONS.get(session_id) or {"verified": False, "customer_id": customer_id, "name": full_name}
352
+ if isinstance(customer_id, str) and customer_id:
353
+ session["customer_id"] = customer_id
354
+ if isinstance(full_name, str) and full_name:
355
+ session["name"] = full_name
356
+ if isinstance(dob_yyyy_mm_dd, str) and dob_yyyy_mm_dd:
357
+ session["dob"] = dob_yyyy_mm_dd
358
+ if isinstance(ssn_last4, str) and ssn_last4:
359
+ session["ssn_last4"] = ssn_last4
360
+ if isinstance(secret_answer, str) and secret_answer:
361
+ session["secret"] = secret_answer
362
+
363
+ ok = False
364
+ cid = session.get("customer_id")
365
+ if isinstance(cid, str):
366
+ prof = get_profile(cid)
367
+ user_dob_norm = _normalize_dob(session.get("dob"))
368
+ prof_dob_norm = _normalize_dob(prof.get("dob"))
369
+ dob_ok = (user_dob_norm is not None) and (user_dob_norm == prof_dob_norm)
370
+ ssn_ok = str(session.get("ssn_last4") or "") == str(prof.get("ssn_last4") or "")
371
+ def _norm(x: Optional[str]) -> str:
372
+ return (x or "").strip().lower()
373
+ secret_ok = _norm(session.get("secret")) == _norm(prof.get("secret_answer"))
374
+ if dob_ok and (ssn_ok or secret_ok):
375
+ ok = True
376
+ session["verified"] = ok
377
+ _SESSIONS[session_id] = session
378
+ need: List[str] = []
379
+ if _normalize_dob(session.get("dob")) is None:
380
+ need.append("dob")
381
+ if not session.get("ssn_last4") and not session.get("secret"):
382
+ need.append("ssn_last4_or_secret")
383
+ if not session.get("customer_id"):
384
+ need.append("customer")
385
+ resp: Dict[str, Any] = {"session_id": session_id, "verified": ok, "needs": need, "profile": {"name": session.get("name")}}
386
+ try:
387
+ if isinstance(session.get("customer_id"), str):
388
+ prof = get_profile(session.get("customer_id"))
389
+ if isinstance(prof, dict) and prof.get("secret_question"):
390
+ resp["question"] = prof.get("secret_question")
391
+ except Exception:
392
+ pass
393
+ return resp
394
+
395
+
396
+ def quote_wire(kind: str, from_account_id: str, beneficiary: Dict[str, Any], amount: float, from_currency: str, to_currency: str, payer: str) -> Dict[str, Any]:
397
+ # FX
398
+ fx = get_exchange_rate(from_currency, to_currency, amount)
399
+ converted_amount = fx["converted_amount"]
400
+ # Fees
401
+ fee = calculate_wire_fee(kind, amount, from_currency, to_currency, payer)
402
+ # Limits and balance
403
+ limits = check_wire_limits(from_account_id, amount)
404
+ if not limits.get("ok"):
405
+ return {"error": "limit_or_balance", "details": limits}
406
+ # Sanctions
407
+ sanc = screen_sanctions(str(beneficiary.get("account_name") or beneficiary.get("name") or ""), str(beneficiary.get("country") or ""))
408
+ if not sanc.get("cleared"):
409
+ return {"error": "sanctions", "details": sanc}
410
+ # ETA
411
+ eta = get_cutoff_and_eta(kind, str(beneficiary.get("country") or ""))
412
+
413
+ payer_opt = (payer or "SHA").upper()
414
+ initiator_fees = float(fee.get("initiator_fees_total", 0.0))
415
+ recipient_fees = float(fee.get("recipient_fees_total", 0.0))
416
+ net_sent = float(amount) + (initiator_fees if payer_opt in ("OUR", "SHA") else 0.0)
417
+ # recipient side fees reduce the amount received when SHA/BEN
418
+ net_received = float(converted_amount)
419
+ if payer_opt in ("SHA", "BEN"):
420
+ net_received = max(0.0, net_received - recipient_fees)
421
+
422
+ qid = f"Q-{uuid.uuid4().hex[:8]}"
423
+ quote = {
424
+ "quote_id": qid,
425
+ "type": kind.upper(),
426
+ "from_account_id": from_account_id,
427
+ "amount": float(amount),
428
+ "from_currency": from_currency.upper(),
429
+ "to_currency": to_currency.upper(),
430
+ "payer": payer_opt,
431
+ "fx": fx,
432
+ "fees": fee,
433
+ "net_sent": round(net_sent, 2),
434
+ "net_received": round(net_received, 2),
435
+ "eta": eta,
436
+ "created_at": datetime.utcnow().isoformat() + "Z",
437
+ "expires_at": (datetime.utcnow().isoformat() + "Z")
438
+ }
439
+ _QUOTES[qid] = quote
440
+ return quote
441
+
442
+
443
+ def wire_transfer_domestic(quote_id: str, otp: str) -> Dict[str, Any]:
444
+ q = _QUOTES.get(quote_id)
445
+ if not q or q.get("type") != "DOMESTIC":
446
+ return {"error": "invalid_quote"}
447
+ # OTP expected: we need customer_id context; skip and assume OTP verified externally
448
+ conf = f"WD-{uuid.uuid4().hex[:8]}"
449
+ return {"confirmation_id": conf, "status": "submitted"}
450
+
451
+
452
+ def wire_transfer_international(quote_id: str, otp: str) -> Dict[str, Any]:
453
+ q = _QUOTES.get(quote_id)
454
+ if not q or q.get("type") != "INTERNATIONAL":
455
+ return {"error": "invalid_quote"}
456
+ conf = f"WI-{uuid.uuid4().hex[:8]}"
457
+ return {"confirmation_id": conf, "status": "submitted"}
458
+
459
+
460
+ def list_transactions(account_id: str, start: Optional[str], end: Optional[str]) -> List[Dict[str, Any]]:
461
+ data = _load_fixture("transactions.json")
462
+ txns = list(data.get(account_id, []))
463
+ if start or end:
464
+ start_dt = _parse_iso_date(start) or datetime.min
465
+ end_dt = _parse_iso_date(end) or datetime.max
466
+ out: List[Dict[str, Any]] = []
467
+ for t in txns:
468
+ td = _parse_iso_date(t.get("date"))
469
+ if td and start_dt <= td <= end_dt:
470
+ out.append(t)
471
+ return out
472
+ return txns
473
+
474
+
475
+ def get_fee_schedule(product_type: str) -> Dict[str, Any]:
476
+ data = _load_fixture("fee_schedules.json")
477
+ return dict(data.get(product_type.upper(), {}))
478
+
479
+
480
+ def detect_fees(transactions: List[Dict[str, Any]], schedule: Dict[str, Any]) -> List[Dict[str, Any]]:
481
+ results: List[Dict[str, Any]] = []
482
+ for t in transactions:
483
+ if str(t.get("entry_type")).upper() == "FEE":
484
+ fee_code = (t.get("fee_code") or "").upper()
485
+ sched_entry = None
486
+ for s in schedule.get("fees", []) or []:
487
+ if str(s.get("code", "")).upper() == fee_code:
488
+ sched_entry = s
489
+ break
490
+ evt = {
491
+ "id": t.get("id") or str(uuid.uuid4()),
492
+ "posted_date": t.get("date"),
493
+ "amount": float(t.get("amount", 0)),
494
+ "description": t.get("description") or fee_code,
495
+ "fee_code": fee_code,
496
+ "schedule": sched_entry or None,
497
+ }
498
+ results.append(evt)
499
+ try:
500
+ results.sort(key=lambda x: x.get("posted_date") or "")
501
+ except Exception:
502
+ pass
503
+ return results
504
+
505
+
506
+ def explain_fee(fee_event: Dict[str, Any]) -> str:
507
+ openai_api_key = os.getenv("OPENAI_API_KEY")
508
+ code = (fee_event.get("fee_code") or "").upper()
509
+ name = fee_event.get("schedule", {}).get("name") or code.title()
510
+ posted = fee_event.get("posted_date") or ""
511
+ amount = float(fee_event.get("amount") or 0)
512
+ policy = fee_event.get("schedule", {}).get("policy") or ""
513
+ if not openai_api_key:
514
+ base = f"You were charged {name} on {posted} for CAD {amount:.2f}."
515
+ if code == "NSF":
516
+ return base + " This is applied when a payment is attempted but the account balance was insufficient."
517
+ if code == "MAINTENANCE":
518
+ return base + " This is the monthly account fee as per your account plan."
519
+ if code == "ATM":
520
+ return base + " This fee applies to certain ATM withdrawals."
521
+ return base + " This fee was identified based on your recent transactions."
522
+
523
+ llm = ChatOpenAI(model=os.getenv("EXPLAIN_MODEL", "gpt-4o"), api_key=openai_api_key)
524
+ chain = EXPLAIN_FEE_PROMPT | llm
525
+ out = chain.invoke(
526
+ {
527
+ "fee_code": code,
528
+ "posted_date": posted,
529
+ "amount": f"{amount:.2f}",
530
+ "schedule_name": name,
531
+ "schedule_policy": policy,
532
+ }
533
+ )
534
+ text = getattr(out, "content", None)
535
+ return text if isinstance(text, str) and text.strip() else f"You were charged {name} on {posted} for CAD {amount:.2f}."
536
+
537
+
538
+ def check_dispute_eligibility(fee_event: Dict[str, Any]) -> Dict[str, Any]:
539
+ code = (fee_event.get("fee_code") or "").upper()
540
+ amount = float(fee_event.get("amount", 0))
541
+ first_time = bool(fee_event.get("first_time_90d", False))
542
+ eligible = False
543
+ reason = ""
544
+ if code in {"NSF", "ATM", "MAINTENANCE", "WITHDRAWAL"} and amount <= 20.0 and first_time:
545
+ eligible = True
546
+ reason = "First occurrence in 90 days and small amount"
547
+ return {"eligible": eligible, "reason": reason}
548
+
549
+
550
+ def create_dispute_case(fee_event: Dict[str, Any], idempotency_key: str) -> Dict[str, Any]:
551
+ if idempotency_key in _DISPUTES_DB:
552
+ return _DISPUTES_DB[idempotency_key]
553
+ case = {
554
+ "case_id": str(uuid.uuid4()),
555
+ "status": "submitted",
556
+ "fee_id": fee_event.get("id"),
557
+ "created_at": datetime.utcnow().isoformat() + "Z",
558
+ }
559
+ _DISPUTES_DB[idempotency_key] = case
560
+ return case
561
+
562
+
563
+ def authenticate_user(session_id: str, name: Optional[str], dob_yyyy_mm_dd: Optional[str], last4: Optional[str], secret_answer: Optional[str], customer_id: Optional[str] = None) -> Dict[str, Any]:
564
+ """Mock identity verification.
565
+
566
+ Rules (mock):
567
+ - If dob == 1990-01-01 and last4 == 6001 or secret_answer == "blue", auth succeeds.
568
+ - Otherwise, remains pending with which fields are still missing.
569
+ Persists per session_id.
570
+ """
571
+ session = _SESSIONS.get(session_id) or {"verified": False, "name": name, "customer_id": customer_id}
572
+ if isinstance(name, str) and name:
573
+ session["name"] = name
574
+ if isinstance(customer_id, str) and customer_id:
575
+ session["customer_id"] = customer_id
576
+ if isinstance(dob_yyyy_mm_dd, str) and dob_yyyy_mm_dd:
577
+ # Normalize DOB to YYYY-MM-DD
578
+ norm = _normalize_dob(dob_yyyy_mm_dd)
579
+ session["dob"] = norm or dob_yyyy_mm_dd
580
+ if isinstance(last4, str) and last4:
581
+ session["last4"] = last4
582
+ if isinstance(secret_answer, str) and secret_answer:
583
+ session["secret"] = secret_answer
584
+
585
+ ok = False
586
+ # If a specific customer is in context, validate against their profile and accounts
587
+ if isinstance(session.get("customer_id"), str):
588
+ prof = get_profile(session.get("customer_id"))
589
+ accts = get_accounts(session.get("customer_id"))
590
+ dob_ok = _normalize_dob(session.get("dob")) == _normalize_dob(prof.get("dob")) and bool(session.get("dob"))
591
+ last4s = {str(a.get("account_number"))[-4:] for a in accts if a.get("account_number")}
592
+ last4_ok = isinstance(session.get("last4"), str) and session.get("last4") in last4s
593
+ def _norm_secret(x: Optional[str]) -> str:
594
+ return (x or "").strip().lower()
595
+ secret_ok = _norm_secret(session.get("secret")) == _norm_secret(prof.get("secret_answer"))
596
+ if dob_ok and (last4_ok or secret_ok):
597
+ ok = True
598
+ else:
599
+ # Optional demo fallback (disabled by default)
600
+ allow_fallback = os.getenv("RBC_FEES_ALLOW_GLOBAL_FALLBACK", "0") not in ("", "0", "false", "False")
601
+ if allow_fallback and session.get("dob") == "1990-01-01" and (session.get("last4") == "6001" or (session.get("secret") or "").strip().lower() == "blue"):
602
+ ok = True
603
+ session["verified"] = ok
604
+ _SESSIONS[session_id] = session
605
+ need: list[str] = []
606
+ if not session.get("dob"):
607
+ need.append("dob")
608
+ if not session.get("last4") and not session.get("secret"):
609
+ need.append("last4_or_secret")
610
+ if not session.get("customer_id"):
611
+ need.append("customer")
612
+ resp: Dict[str, Any] = {"session_id": session_id, "verified": ok, "needs": need, "profile": {"name": session.get("name")}}
613
+ try:
614
+ if isinstance(session.get("customer_id"), str):
615
+ prof = get_profile(session.get("customer_id"))
616
+ if isinstance(prof, dict) and prof.get("secret_question"):
617
+ resp["question"] = prof.get("secret_question")
618
+ except Exception:
619
+ pass
620
+ return resp
621
+
622
+
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/patients.json ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "patients": {
3
+ "pt_jmarshall": {
4
+ "profile": {
5
+ "patient_id": "pt_jmarshall",
6
+ "first_name": "John",
7
+ "last_name": "Marshall",
8
+ "full_name": "John Marshall",
9
+ "dob": "1960-01-01",
10
+ "mrn": "JM-1960-0001",
11
+ "mrn_last4": "0001",
12
+ "secret_question": "What is your favorite color?",
13
+ "secret_answer": "blue",
14
+ "phone": "+1-555-0101",
15
+ "email": "john.marshall@example.com",
16
+ "address": {
17
+ "line1": "12 Park Ave",
18
+ "city": "Santa Clara",
19
+ "state": "CA",
20
+ "zip": "95050"
21
+ },
22
+ "preferred_pharmacy_id": "ph_sc_1010"
23
+ },
24
+ "allergies": [
25
+ "Penicillin"
26
+ ],
27
+ "medications": [
28
+ {
29
+ "name": "Acetaminophen",
30
+ "sig": "500 mg, every 6 hours as needed for pain",
31
+ "rxnorm": "161",
32
+ "otc": true
33
+ }
34
+ ],
35
+ "conditions": [
36
+ "Hypertension"
37
+ ],
38
+ "recent_visits": [
39
+ {
40
+ "date": "2025-08-15",
41
+ "type": "Primary Care",
42
+ "reason": "Annual wellness visit",
43
+ "outcome": "Continue current meds"
44
+ }
45
+ ],
46
+ "vitals": {
47
+ "last": {
48
+ "date": "2025-08-15",
49
+ "bp": "128/78",
50
+ "hr": 72,
51
+ "temp_f": 98.4,
52
+ "bmi": 26.1
53
+ }
54
+ }
55
+ },
56
+ "pt_fciannella": {
57
+ "profile": {
58
+ "patient_id": "pt_fciannella",
59
+ "first_name": "Francesco",
60
+ "last_name": "Ciannella",
61
+ "full_name": "Francesco Ciannella",
62
+ "dob": "1990-01-01",
63
+ "mrn": "FC-1990-6001",
64
+ "mrn_last4": "6001",
65
+ "secret_question": "What city were you born in?",
66
+ "secret_answer": "rome",
67
+ "phone": "+1-555-0202",
68
+ "email": "francesco@example.com",
69
+ "address": {
70
+ "line1": "101 State St",
71
+ "city": "San Jose",
72
+ "state": "CA",
73
+ "zip": "95110"
74
+ },
75
+ "preferred_pharmacy_id": "ph_sc_1010"
76
+ },
77
+ "allergies": [
78
+ "NKA"
79
+ ],
80
+ "medications": [
81
+ {
82
+ "name": "Lisinopril",
83
+ "sig": "10 mg, once daily",
84
+ "rxnorm": "29046",
85
+ "otc": false
86
+ }
87
+ ],
88
+ "conditions": [
89
+ "Hypertension"
90
+ ],
91
+ "recent_visits": [
92
+ {
93
+ "date": "2025-07-02",
94
+ "type": "Urgent Care",
95
+ "reason": "Sore throat",
96
+ "outcome": "Viral pharyngitis; supportive care"
97
+ }
98
+ ],
99
+ "vitals": {
100
+ "last": {
101
+ "date": "2025-07-02",
102
+ "bp": "122/76",
103
+ "hr": 68,
104
+ "temp_f": 98.2,
105
+ "bmi": 24.9
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/pharmacies.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pharmacies": {
3
+ "ph_sc_1010": {
4
+ "name": "Community Pharmacy",
5
+ "address": "1010 El Camino Real, Santa Clara, CA 95050",
6
+ "phone": "+1-408-555-3131",
7
+ "hours": "Mon-Sun 8:00-22:00"
8
+ },
9
+ "ph_sj_center": {
10
+ "name": "Center Care Pharmacy",
11
+ "address": "500 Market St, San Jose, CA 95113",
12
+ "phone": "+1-408-555-1212",
13
+ "hours": "Mon-Fri 9:00-18:00, Sat 10:00-16:00"
14
+ }
15
+ }
16
+ }
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/providers.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "providers": {
3
+ "prov_smith_md": {
4
+ "name": "Dr. Laura Smith",
5
+ "type": "MD",
6
+ "specialty": "Primary Care",
7
+ "location": "Main Clinic - Santa Clara",
8
+ "next_available": [
9
+ "2025-09-27T10:00:00-07:00",
10
+ "2025-09-27T14:30:00-07:00",
11
+ "2025-09-28T09:15:00-07:00"
12
+ ]
13
+ },
14
+ "prov_chang_np": {
15
+ "name": "Alex Chang, NP",
16
+ "type": "NP",
17
+ "specialty": "Urgent Care",
18
+ "location": "Telehealth",
19
+ "next_available": [
20
+ "2025-09-26T20:00:00-07:00",
21
+ "2025-09-27T08:30:00-07:00",
22
+ "2025-09-27T16:00:00-07:00"
23
+ ]
24
+ }
25
+ }
26
+ }
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/triage_rules.json ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "rules": [
3
+ {
4
+ "name": "Headache - red flags",
5
+ "match": ["headache"],
6
+ "red_flags": [
7
+ "worst headache",
8
+ "head injury",
9
+ "numbness",
10
+ "weakness",
11
+ "confusion",
12
+ "seizure",
13
+ "stiff neck",
14
+ "vision loss",
15
+ "high fever",
16
+ "fever 102",
17
+ "cancer",
18
+ "pregnant"
19
+ ],
20
+ "escalate": "urgent",
21
+ "advice": "This may require urgent evaluation. If you have any red flag symptoms, go to the nearest emergency department or call emergency services.",
22
+ "book_with": "prov_chang_np"
23
+ },
24
+ {
25
+ "name": "Headache - moderate risk (age > 50 new onset)",
26
+ "match": ["headache"],
27
+ "criteria": ["age_over_50"],
28
+ "escalate": "soon",
29
+ "advice": "Given your age and new headache, we recommend a prompt appointment to evaluate further.",
30
+ "book_with": "prov_smith_md"
31
+ },
32
+ {
33
+ "name": "Headache - typical",
34
+ "match": ["headache"],
35
+ "escalate": "self_care",
36
+ "advice": "Try rest, hydration, limited screen time, and acetaminophen as directed. Avoid ibuprofen if your doctor advised against it. If symptoms worsen or persist beyond 48 hours, schedule a visit.",
37
+ "book_with": "prov_smith_md"
38
+ },
39
+ {
40
+ "name": "Chest pain - emergency",
41
+ "match": ["chest pain"],
42
+ "escalate": "urgent",
43
+ "advice": "Chest pain can be serious. Please call emergency services now or go to the nearest ER.",
44
+ "book_with": "prov_chang_np"
45
+ },
46
+ {
47
+ "name": "Fever",
48
+ "match": ["fever"],
49
+ "escalate": "self_care",
50
+ "advice": "Hydration, rest, and acetaminophen can help. If fever is 102°F or higher, lasts more than 3 days, or you have trouble breathing, seek care.",
51
+ "book_with": "prov_smith_md"
52
+ }
53
+ ]
54
+ }
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/prompts.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.prompts import ChatPromptTemplate
2
+
3
+ # Turn a structured fee event into a friendly, empathetic explanation
4
+ EXPLAIN_FEE_PROMPT = ChatPromptTemplate.from_messages([
5
+ (
6
+ "system",
7
+ """
8
+ You are a warm, cheerful banking assistant speaking on the phone. Use a friendly, empathetic tone.
9
+ Guidelines:
10
+ - Start with brief empathy (e.g., "I know surprise fees can be frustrating.").
11
+ - Clearly explain what the fee is and why it was applied.
12
+ - Keep it concise (2–3 sentences), plain language, no jargon.
13
+ - Offer help-oriented phrasing ("we can look into options"), no blame.
14
+ """,
15
+ ),
16
+ (
17
+ "human",
18
+ """
19
+ Fee event:
20
+ - code: {fee_code}
21
+ - posted_date: {posted_date}
22
+ - amount: {amount}
23
+ - schedule_name: {schedule_name}
24
+ - schedule_policy: {schedule_policy}
25
+
26
+ Write a concise explanation (2–3 sentences) suitable for a mobile UI.
27
+ """,
28
+ ),
29
+ ])
30
+
31
+
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/react_agent.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import logging
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+
8
+ from langgraph.func import entrypoint, task
9
+ from langgraph.graph import add_messages
10
+ from langchain_openai import ChatOpenAI
11
+ from langchain_core.messages import (
12
+ SystemMessage,
13
+ HumanMessage,
14
+ AIMessage,
15
+ BaseMessage,
16
+ ToolCall,
17
+ ToolMessage,
18
+ )
19
+
20
+
21
+ # ---- Tools (wire-transfer) ----
22
+
23
+ try:
24
+ from . import tools as wire_tools # type: ignore
25
+ except Exception:
26
+ import importlib.util as _ilu
27
+ _dir = os.path.dirname(__file__)
28
+ _tools_path = os.path.join(_dir, "tools.py")
29
+ _spec = _ilu.spec_from_file_location("wire_transfer_agent_tools", _tools_path)
30
+ wire_tools = _ilu.module_from_spec(_spec) # type: ignore
31
+ assert _spec and _spec.loader
32
+ _spec.loader.exec_module(wire_tools) # type: ignore
33
+
34
+ # Aliases for tool functions
35
+ list_accounts = wire_tools.list_accounts
36
+ get_customer_profile = wire_tools.get_customer_profile
37
+ find_customer = wire_tools.find_customer
38
+ find_account_by_last4 = wire_tools.find_account_by_last4
39
+ verify_identity = wire_tools.verify_identity
40
+ get_account_balance_tool = wire_tools.get_account_balance_tool
41
+ get_exchange_rate_tool = wire_tools.get_exchange_rate_tool
42
+ calculate_wire_fee_tool = wire_tools.calculate_wire_fee_tool
43
+ check_wire_limits_tool = wire_tools.check_wire_limits_tool
44
+ get_cutoff_and_eta_tool = wire_tools.get_cutoff_and_eta_tool
45
+ get_country_requirements_tool = wire_tools.get_country_requirements_tool
46
+ validate_beneficiary_tool = wire_tools.validate_beneficiary_tool
47
+ save_beneficiary_tool = wire_tools.save_beneficiary_tool
48
+ quote_wire_tool = wire_tools.quote_wire_tool
49
+ generate_otp_tool = wire_tools.generate_otp_tool
50
+ verify_otp_tool = wire_tools.verify_otp_tool
51
+ wire_transfer_domestic = wire_tools.wire_transfer_domestic
52
+ wire_transfer_international = wire_tools.wire_transfer_international
53
+
54
+ find_customer_by_name = None # not used for wire agent; tools expose find_customer
55
+
56
+
57
+ """ReAct agent entrypoint and system prompt."""
58
+
59
+
60
+ SYSTEM_PROMPT = (
61
+ "You are a warm, cheerful banking assistant helping a customer send a wire transfer (domestic or international). "
62
+ "Start with a brief greeting and very short small talk. Then ask for the caller's full name. "
63
+ "IDENTITY IS MANDATORY: Before ANY account lookups or wire questions, you MUST call verify_identity. Ask for date of birth (customer can use any format; you normalize) and EITHER SSN last-4 OR the secret answer. If verify_identity returns a secret question, read it verbatim and collect the answer. "
64
+ "NEVER claim the customer is verified unless the verify_identity tool returned verified=true. If not verified, ask ONLY for the next missing field and call verify_identity again. Do NOT proceed to wire details until verified=true. "
65
+ "AFTER VERIFIED: Ask ONE question at a time, in this order, waiting for the user's answer each time: (1) wire type (DOMESTIC or INTERNATIONAL); (2) source account (last-4 or picker); (3) amount (with source currency); (4) destination country/state; (5) destination currency preference; (6) who pays fees (OUR/SHA/BEN). Keep each turn to a single, concise prompt. Do NOT re-ask for fields already provided; instead, briefly summarize known details and ask only for the next missing field. "
66
+ "If destination currency differs from source, call get_exchange_rate_tool and state the applied rate and converted amount. "
67
+ "Collect beneficiary details next. Use get_country_requirements_tool and validate_beneficiary_tool; if fields are missing, ask for ONLY the next missing field (one per turn). "
68
+ "Then check balance/limits via get_account_balance_tool and check_wire_limits_tool. Provide a pre-transfer quote using quote_wire_tool showing: FX rate, total fees, who pays what, net sent, net received, and ETA from get_cutoff_and_eta_tool. "
69
+ "Before executing, generate an OTP (generate_otp_tool), collect it, verify via verify_otp_tool, then execute the appropriate transfer: wire_transfer_domestic or wire_transfer_international. Offer to save the beneficiary afterward. "
70
+ "STYLE: Keep messages short (1–2 sentences), empathetic, and strictly ask one question per turn."
71
+ )
72
+
73
+
74
+ _MODEL_NAME = os.getenv("REACT_MODEL", os.getenv("CLARIFY_MODEL", "gpt-4o"))
75
+ _LLM = ChatOpenAI(model=_MODEL_NAME, temperature=0.3)
76
+ _TOOLS = [
77
+ list_accounts,
78
+ get_customer_profile,
79
+ find_customer,
80
+ find_account_by_last4,
81
+ verify_identity,
82
+ get_account_balance_tool,
83
+ get_exchange_rate_tool,
84
+ calculate_wire_fee_tool,
85
+ check_wire_limits_tool,
86
+ get_cutoff_and_eta_tool,
87
+ get_country_requirements_tool,
88
+ validate_beneficiary_tool,
89
+ save_beneficiary_tool,
90
+ quote_wire_tool,
91
+ generate_otp_tool,
92
+ verify_otp_tool,
93
+ wire_transfer_domestic,
94
+ wire_transfer_international,
95
+ ]
96
+ _LLM_WITH_TOOLS = _LLM.bind_tools(_TOOLS)
97
+ _TOOLS_BY_NAME = {t.name: t for t in _TOOLS}
98
+
99
+ # Simple per-run context storage (thread-safe enough for local dev worker)
100
+ _CURRENT_THREAD_ID: str | None = None
101
+ _CURRENT_CUSTOMER_ID: str | None = None
102
+
103
+ # ---- Logger ----
104
+ logger = logging.getLogger("WireTransferAgent")
105
+ if not logger.handlers:
106
+ _stream = logging.StreamHandler()
107
+ _stream.setLevel(logging.INFO)
108
+ _fmt = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
109
+ _stream.setFormatter(_fmt)
110
+ logger.addHandler(_stream)
111
+ try:
112
+ _file = logging.FileHandler(str(Path(__file__).resolve().parents[2] / "app.log"))
113
+ _file.setLevel(logging.INFO)
114
+ _file.setFormatter(_fmt)
115
+ logger.addHandler(_file)
116
+ except Exception:
117
+ pass
118
+ logger.setLevel(logging.INFO)
119
+ _DEBUG = os.getenv("RBC_FEES_DEBUG", "0") not in ("", "0", "false", "False")
120
+
121
+ def _get_thread_id(config: Dict[str, Any] | None, messages: List[BaseMessage]) -> str:
122
+ cfg = config or {}
123
+ # Try dict-like and attribute-like access
124
+ def _safe_get(container: Any, key: str, default: Any = None) -> Any:
125
+ try:
126
+ if isinstance(container, dict):
127
+ return container.get(key, default)
128
+ if hasattr(container, "get"):
129
+ return container.get(key, default)
130
+ if hasattr(container, key):
131
+ return getattr(container, key, default)
132
+ except Exception:
133
+ return default
134
+ return default
135
+
136
+ try:
137
+ conf = _safe_get(cfg, "configurable", {}) or {}
138
+ for key in ("thread_id", "session_id", "thread"):
139
+ val = _safe_get(conf, key)
140
+ if isinstance(val, str) and val:
141
+ return val
142
+ except Exception:
143
+ pass
144
+
145
+ # Fallback: look for session_id on the latest human message additional_kwargs
146
+ try:
147
+ for m in reversed(messages or []):
148
+ addl = getattr(m, "additional_kwargs", None)
149
+ if isinstance(addl, dict) and isinstance(addl.get("session_id"), str) and addl.get("session_id"):
150
+ return addl.get("session_id")
151
+ if isinstance(m, dict):
152
+ ak = m.get("additional_kwargs") or {}
153
+ if isinstance(ak, dict) and isinstance(ak.get("session_id"), str) and ak.get("session_id"):
154
+ return ak.get("session_id")
155
+ except Exception:
156
+ pass
157
+ return "unknown"
158
+
159
+
160
+ def _trim_messages(messages: List[BaseMessage], max_messages: int = 40) -> List[BaseMessage]:
161
+ if len(messages) <= max_messages:
162
+ return messages
163
+ return messages[-max_messages:]
164
+
165
+
166
+ def _sanitize_conversation(messages: List[BaseMessage]) -> List[BaseMessage]:
167
+ """Ensure tool messages only follow an assistant message with tool_calls.
168
+
169
+ Drops orphan tool messages that could cause OpenAI 400 errors.
170
+ """
171
+ sanitized: List[BaseMessage] = []
172
+ pending_tool_ids: set[str] | None = None
173
+ for m in messages:
174
+ try:
175
+ if isinstance(m, AIMessage):
176
+ sanitized.append(m)
177
+ tool_calls = getattr(m, "tool_calls", None) or []
178
+ ids: set[str] = set()
179
+ for tc in tool_calls:
180
+ # ToolCall can be mapping-like or object-like
181
+ if isinstance(tc, dict):
182
+ _id = tc.get("id") or tc.get("tool_call_id")
183
+ else:
184
+ _id = getattr(tc, "id", None) or getattr(tc, "tool_call_id", None)
185
+ if isinstance(_id, str):
186
+ ids.add(_id)
187
+ pending_tool_ids = ids if ids else None
188
+ continue
189
+ if isinstance(m, ToolMessage):
190
+ if pending_tool_ids and isinstance(getattr(m, "tool_call_id", None), str) and m.tool_call_id in pending_tool_ids:
191
+ sanitized.append(m)
192
+ # keep accepting subsequent tool messages for the same assistant turn
193
+ continue
194
+ # Orphan tool message: drop
195
+ continue
196
+ # Any other message resets expectation
197
+ sanitized.append(m)
198
+ pending_tool_ids = None
199
+ except Exception:
200
+ # On any unexpected shape, include as-is but reset to avoid pairing issues
201
+ sanitized.append(m)
202
+ pending_tool_ids = None
203
+ # Ensure the conversation doesn't start with a ToolMessage
204
+ while sanitized and isinstance(sanitized[0], ToolMessage):
205
+ sanitized.pop(0)
206
+ return sanitized
207
+
208
+
209
+ def _today_string() -> str:
210
+ override = os.getenv("RBC_FEES_TODAY_OVERRIDE")
211
+ if isinstance(override, str) and override.strip():
212
+ try:
213
+ datetime.strptime(override.strip(), "%Y-%m-%d")
214
+ return override.strip()
215
+ except Exception:
216
+ pass
217
+ return datetime.utcnow().strftime("%Y-%m-%d")
218
+
219
+
220
+ def _system_messages() -> List[BaseMessage]:
221
+ today = _today_string()
222
+ return [SystemMessage(content=SYSTEM_PROMPT)]
223
+
224
+
225
+ @task()
226
+ def call_llm(messages: List[BaseMessage]) -> AIMessage:
227
+ """LLM decides whether to call a tool or not."""
228
+ if _DEBUG:
229
+ try:
230
+ preview = [f"{getattr(m,'type', getattr(m,'role',''))}:{str(getattr(m,'content', m))[:80]}" for m in messages[-6:]]
231
+ logger.info("call_llm: messages_count=%s preview=%s", len(messages), preview)
232
+ except Exception:
233
+ logger.info("call_llm: messages_count=%s", len(messages))
234
+ resp = _LLM_WITH_TOOLS.invoke(_system_messages() + messages)
235
+ try:
236
+ # Log assistant content or tool calls for visibility
237
+ tool_calls = getattr(resp, "tool_calls", None) or []
238
+ if tool_calls:
239
+ names = []
240
+ for tc in tool_calls:
241
+ n = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
242
+ if isinstance(n, str):
243
+ names.append(n)
244
+ logger.info("LLM tool_calls: %s", names)
245
+ else:
246
+ txt = getattr(resp, "content", "") or ""
247
+ if isinstance(txt, str) and txt.strip():
248
+ logger.info("LLM content: %s", (txt if len(txt) <= 500 else (txt[:500] + "…")))
249
+ except Exception:
250
+ pass
251
+ return resp
252
+
253
+
254
+ @task()
255
+ def call_tool(tool_call: ToolCall) -> ToolMessage:
256
+ """Execute a tool call and wrap result in a ToolMessage."""
257
+ tool = _TOOLS_BY_NAME[tool_call["name"]]
258
+ args = tool_call.get("args") or {}
259
+ # Auto-inject session/customer context if missing for identity and other tools
260
+ if tool.name == "verify_identity":
261
+ if "session_id" not in args and _CURRENT_THREAD_ID:
262
+ args["session_id"] = _CURRENT_THREAD_ID
263
+ if "customer_id" not in args and _CURRENT_CUSTOMER_ID:
264
+ args["customer_id"] = _CURRENT_CUSTOMER_ID
265
+ if tool.name == "list_accounts":
266
+ if "customer_id" not in args and _CURRENT_CUSTOMER_ID:
267
+ args["customer_id"] = _CURRENT_CUSTOMER_ID
268
+ # Gate non-identity tools until verified=true
269
+ try:
270
+ if tool.name not in ("verify_identity", "find_customer"):
271
+ # Look back through recent messages for the last verify_identity result
272
+ # The runtime passes messages separately; we cannot access here, so rely on LLM prompt discipline.
273
+ # As an extra guard, if the tool is attempting a wire action before identity, return a friendly error.
274
+ pass
275
+ except Exception:
276
+ pass
277
+ if _DEBUG:
278
+ try:
279
+ logger.info("call_tool: name=%s args_keys=%s", tool.name, list(args.keys()))
280
+ except Exception:
281
+ logger.info("call_tool: name=%s", tool.name)
282
+ result = tool.invoke(args)
283
+ # Ensure string content
284
+ content = result if isinstance(result, str) else json.dumps(result)
285
+ try:
286
+ # Log tool result previews and OTP debug_code when present
287
+ if tool.name == "verify_identity":
288
+ try:
289
+ data = json.loads(content)
290
+ logger.info("verify_identity: verified=%s needs=%s", data.get("verified"), data.get("needs"))
291
+ except Exception:
292
+ logger.info("verify_identity result: %s", content[:300])
293
+ elif tool.name == "generate_otp_tool":
294
+ try:
295
+ data = json.loads(content)
296
+ if isinstance(data, dict) and data.get("debug_code"):
297
+ logger.info("OTP debug_code: %s", data.get("debug_code"))
298
+ else:
299
+ logger.info("generate_otp_tool: %s", content[:300])
300
+ except Exception:
301
+ logger.info("generate_otp_tool: %s", content[:300])
302
+ else:
303
+ # Generic preview
304
+ logger.info("tool %s result: %s", tool.name, (content[:300] if isinstance(content, str) else str(content)[:300]))
305
+ except Exception:
306
+ pass
307
+ # Never expose OTP debug_code to the LLM
308
+ try:
309
+ if tool.name == "generate_otp_tool":
310
+ data = json.loads(content)
311
+ if isinstance(data, dict) and "debug_code" in data:
312
+ data.pop("debug_code", None)
313
+ content = json.dumps(data)
314
+ except Exception:
315
+ pass
316
+ return ToolMessage(content=content, tool_call_id=tool_call["id"], name=tool.name)
317
+
318
+
319
+ @entrypoint()
320
+ def agent(messages: List[BaseMessage], previous: List[BaseMessage] | None, config: Dict[str, Any] | None = None):
321
+ # Start from full conversation history (previous + new)
322
+ prev_list = list(previous or [])
323
+ new_list = list(messages or [])
324
+ convo: List[BaseMessage] = prev_list + new_list
325
+ # Trim to avoid context bloat
326
+ convo = _trim_messages(convo, max_messages=int(os.getenv("RBC_FEES_MAX_MSGS", "40")))
327
+ # Sanitize to avoid orphan tool messages after trimming
328
+ convo = _sanitize_conversation(convo)
329
+ thread_id = _get_thread_id(config, new_list)
330
+ logger.info("agent start: thread_id=%s total_in=%s (prev=%s, new=%s)", thread_id, len(convo), len(prev_list), len(new_list))
331
+ # Establish default customer from config (or fallback to cust_test)
332
+ conf = (config or {}).get("configurable", {}) if isinstance(config, dict) else {}
333
+ default_customer = conf.get("customer_id") or conf.get("user_email") or "cust_test"
334
+
335
+ # Heuristic: infer customer_id from latest human name if provided (e.g., "I am Alice Stone")
336
+ inferred_customer: str | None = None
337
+ try:
338
+ recent_humans = [m for m in reversed(new_list) if (getattr(m, "type", None) == "human" or getattr(m, "role", None) == "user" or (isinstance(m, dict) and m.get("type") == "human"))]
339
+ text = None
340
+ for m in recent_humans[:3]:
341
+ text = (getattr(m, "content", None) if not isinstance(m, dict) else m.get("content")) or ""
342
+ if isinstance(text, str) and text.strip():
343
+ break
344
+ if isinstance(text, str):
345
+ tokens = [t for t in text.replace(',', ' ').split() if t.isalpha()]
346
+ if len(tokens) >= 2 and find_customer_by_name is not None:
347
+ # Try adjacent pairs as first/last
348
+ for i in range(len(tokens) - 1):
349
+ fn = tokens[i]
350
+ ln = tokens[i + 1]
351
+ found = find_customer_by_name(fn, ln) # type: ignore
352
+ if isinstance(found, dict) and found.get("customer_id"):
353
+ inferred_customer = found.get("customer_id")
354
+ break
355
+ except Exception:
356
+ pass
357
+
358
+ # Update module context
359
+ global _CURRENT_THREAD_ID, _CURRENT_CUSTOMER_ID
360
+ _CURRENT_THREAD_ID = thread_id
361
+ _CURRENT_CUSTOMER_ID = inferred_customer or default_customer
362
+
363
+ llm_response = call_llm(convo).result()
364
+
365
+ while True:
366
+ tool_calls = getattr(llm_response, "tool_calls", None) or []
367
+ if not tool_calls:
368
+ break
369
+
370
+ # Execute tools (in parallel) and append results
371
+ futures = [call_tool(tc) for tc in tool_calls]
372
+ tool_results = [f.result() for f in futures]
373
+ if _DEBUG:
374
+ try:
375
+ logger.info("tool_results: count=%s names=%s", len(tool_results), [tr.name for tr in tool_results])
376
+ except Exception:
377
+ pass
378
+ convo = add_messages(convo, [llm_response, *tool_results])
379
+ llm_response = call_llm(convo).result()
380
+
381
+ # Append final assistant turn
382
+ convo = add_messages(convo, [llm_response])
383
+ final_text = getattr(llm_response, "content", "") or ""
384
+ try:
385
+ if isinstance(final_text, str) and final_text.strip():
386
+ logger.info("final content: %s", (final_text if len(final_text) <= 500 else (final_text[:500] + "…")))
387
+ except Exception:
388
+ pass
389
+ ai = AIMessage(content=final_text if isinstance(final_text, str) else str(final_text))
390
+ logger.info("agent done: thread_id=%s total_messages=%s final_len=%s", thread_id, len(convo), len(ai.content))
391
+ # Save only the merged conversation (avoid duplicating previous)
392
+ return entrypoint.final(value=ai, save=convo)
393
+
394
+
examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/tools.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ from typing import Any, Dict
5
+
6
+ from langchain_core.tools import tool
7
+
8
+ # Robust logic import to avoid crossing into other agent modules during hot reloads
9
+ try:
10
+ from . import logic as wt_logic # type: ignore
11
+ except Exception:
12
+ import importlib.util as _ilu
13
+ _dir = os.path.dirname(__file__)
14
+ _logic_path = os.path.join(_dir, "logic.py")
15
+ _spec = _ilu.spec_from_file_location("wire_transfer_agent_logic", _logic_path)
16
+ wt_logic = _ilu.module_from_spec(_spec) # type: ignore
17
+ assert _spec and _spec.loader
18
+ _spec.loader.exec_module(wt_logic) # type: ignore
19
+
20
+ get_accounts = wt_logic.get_accounts
21
+ get_profile = wt_logic.get_profile
22
+ find_customer_by_name = wt_logic.find_customer_by_name
23
+ find_customer_by_full_name = getattr(wt_logic, "find_customer_by_full_name", wt_logic.find_customer_by_name)
24
+ get_account_balance = wt_logic.get_account_balance
25
+ get_exchange_rate = wt_logic.get_exchange_rate
26
+ calculate_wire_fee = wt_logic.calculate_wire_fee
27
+ check_wire_limits = wt_logic.check_wire_limits
28
+ get_cutoff_and_eta = wt_logic.get_cutoff_and_eta
29
+ get_country_requirements = wt_logic.get_country_requirements
30
+ validate_beneficiary = wt_logic.validate_beneficiary
31
+ save_beneficiary = wt_logic.save_beneficiary
32
+ generate_otp = wt_logic.generate_otp
33
+ verify_otp = wt_logic.verify_otp
34
+ authenticate_user_wire = wt_logic.authenticate_user_wire
35
+ quote_wire = wt_logic.quote_wire
36
+ wire_transfer_domestic_logic = wt_logic.wire_transfer_domestic
37
+ wire_transfer_international_logic = wt_logic.wire_transfer_international
38
+
39
+
40
+ @tool
41
+ def list_accounts(customer_id: str) -> str:
42
+ """List customer's accounts with masked numbers, balances, currency, and wire eligibility. Returns JSON string."""
43
+ return json.dumps(get_accounts(customer_id))
44
+
45
+
46
+ @tool
47
+ def get_customer_profile(customer_id: str) -> str:
48
+ """Fetch basic customer profile (full_name, dob, ssn_last4, secret question). Returns JSON string."""
49
+ return json.dumps(get_profile(customer_id))
50
+
51
+
52
+ @tool
53
+ def find_customer(first_name: str | None = None, last_name: str | None = None, full_name: str | None = None) -> str:
54
+ """Find a customer_id by name. Prefer full_name; otherwise use first and last name. Returns JSON with customer_id or {}."""
55
+ if isinstance(full_name, str) and full_name.strip():
56
+ return json.dumps(find_customer_by_full_name(full_name))
57
+ return json.dumps(find_customer_by_name(first_name or "", last_name or ""))
58
+
59
+
60
+ @tool
61
+ def find_account_by_last4(customer_id: str, last4: str) -> str:
62
+ """Find a customer's account by last 4 digits. Returns JSON with account or {} if not found."""
63
+ accts = get_accounts(customer_id)
64
+ for a in accts:
65
+ num = str(a.get("account_number") or "")
66
+ if num.endswith(str(last4)):
67
+ return json.dumps(a)
68
+ return json.dumps({})
69
+
70
+
71
+ @tool
72
+ def verify_identity(session_id: str, customer_id: str | None = None, full_name: str | None = None, dob_yyyy_mm_dd: str | None = None, ssn_last4: str | None = None, secret_answer: str | None = None) -> str:
73
+ """Verify user identity before wires. Provide any of: full_name, dob (YYYY-MM-DD), ssn_last4, secret_answer. Returns JSON with verified flag, needed fields, and optional secret question."""
74
+ res = authenticate_user_wire(session_id, customer_id, full_name, dob_yyyy_mm_dd, ssn_last4, secret_answer)
75
+ return json.dumps(res)
76
+
77
+
78
+ @tool
79
+ def get_account_balance_tool(account_id: str) -> str:
80
+ """Get balance, currency, and wire limits for an account. Returns JSON."""
81
+ return json.dumps(get_account_balance(account_id))
82
+
83
+
84
+ @tool
85
+ def get_exchange_rate_tool(from_currency: str, to_currency: str, amount: float) -> str:
86
+ """Get exchange rate and converted amount for a given amount. Returns JSON."""
87
+ return json.dumps(get_exchange_rate(from_currency, to_currency, amount))
88
+
89
+
90
+ @tool
91
+ def calculate_wire_fee_tool(kind: str, amount: float, from_currency: str, to_currency: str, payer: str) -> str:
92
+ """Calculate wire fee breakdown and who pays (OUR/SHA/BEN). Returns JSON."""
93
+ return json.dumps(calculate_wire_fee(kind, amount, from_currency, to_currency, payer))
94
+
95
+
96
+ @tool
97
+ def check_wire_limits_tool(account_id: str, amount: float) -> str:
98
+ """Check sufficient funds and daily wire limit on an account. Returns JSON."""
99
+ return json.dumps(check_wire_limits(account_id, amount))
100
+
101
+
102
+ @tool
103
+ def get_cutoff_and_eta_tool(kind: str, country: str) -> str:
104
+ """Get cutoff time and estimated arrival window by type and country. Returns JSON."""
105
+ return json.dumps(get_cutoff_and_eta(kind, country))
106
+
107
+
108
+ @tool
109
+ def get_country_requirements_tool(country_code: str) -> str:
110
+ """Get required beneficiary fields for a country. Returns JSON array."""
111
+ return json.dumps(get_country_requirements(country_code))
112
+
113
+
114
+ @tool
115
+ def validate_beneficiary_tool(country_code: str, beneficiary_json: str) -> str:
116
+ """Validate beneficiary fields for a given country. Input is JSON dict string; returns {ok, missing}."""
117
+ try:
118
+ beneficiary = json.loads(beneficiary_json)
119
+ except Exception:
120
+ beneficiary = {}
121
+ return json.dumps(validate_beneficiary(country_code, beneficiary))
122
+
123
+
124
+ @tool
125
+ def save_beneficiary_tool(customer_id: str, beneficiary_json: str) -> str:
126
+ """Save a beneficiary for future use. Input is JSON dict string; returns {beneficiary_id}."""
127
+ try:
128
+ beneficiary = json.loads(beneficiary_json)
129
+ except Exception:
130
+ beneficiary = {}
131
+ return json.dumps(save_beneficiary(customer_id, beneficiary))
132
+
133
+
134
+ @tool
135
+ def quote_wire_tool(kind: str, from_account_id: str, beneficiary_json: str, amount: float, from_currency: str, to_currency: str, payer: str) -> str:
136
+ """Create a wire quote including FX, fees, limits, sanctions, eta; returns JSON with quote_id and totals."""
137
+ try:
138
+ beneficiary = json.loads(beneficiary_json)
139
+ except Exception:
140
+ beneficiary = {}
141
+ return json.dumps(quote_wire(kind, from_account_id, beneficiary, amount, from_currency, to_currency, payer))
142
+
143
+
144
+ @tool
145
+ def generate_otp_tool(customer_id: str) -> str:
146
+ """Generate a one-time passcode for wire authorization. Returns masked destination info."""
147
+ return json.dumps(generate_otp(customer_id))
148
+
149
+
150
+ @tool
151
+ def verify_otp_tool(customer_id: str, otp: str) -> str:
152
+ """Verify the one-time passcode for wire authorization. Returns {verified}."""
153
+ return json.dumps(verify_otp(customer_id, otp))
154
+
155
+
156
+ @tool
157
+ def wire_transfer_domestic(quote_id: str, otp: str) -> str:
158
+ """Execute a domestic wire with a valid quote_id and OTP. Returns confirmation."""
159
+ return json.dumps(wire_transfer_domestic_logic(quote_id, otp))
160
+
161
+
162
+ @tool
163
+ def wire_transfer_international(quote_id: str, otp: str) -> str:
164
+ """Execute an international wire with a valid quote_id and OTP. Returns confirmation."""
165
+ return json.dumps(wire_transfer_international_logic(quote_id, otp))
166
+
167
+
examples/voice_agent_webrtc_langgraph/agents/langgraph.json CHANGED
@@ -3,7 +3,8 @@
3
  "graphs": {
4
  "rbc-fees-agent": "./rbc-fees-agent/react_agent.py:agent",
5
  "wire-transfer-agent": "./wire-transfer-agent/react_agent.py:agent",
6
- "telco-agent": "./telco-agent/react_agent.py:agent"
 
7
  },
8
  "dockerfile_lines": [
9
  "RUN pip install --upgrade pip",
 
3
  "graphs": {
4
  "rbc-fees-agent": "./rbc-fees-agent/react_agent.py:agent",
5
  "wire-transfer-agent": "./wire-transfer-agent/react_agent.py:agent",
6
+ "telco-agent": "./telco-agent/react_agent.py:agent",
7
+ "healthcare-agent": "./healthcare-agent/react_agent.py:agent"
8
  },
9
  "dockerfile_lines": [
10
  "RUN pip install --upgrade pip",
examples/voice_agent_webrtc_langgraph/pipeline.py CHANGED
@@ -90,6 +90,7 @@ async def list_assistants(request: Request):
90
  {"assistant_id": "rbc-fees-agent", "graph_id": "rbc-fees-agent", "display_name": "RBC Fees Agent"},
91
  {"assistant_id": "wire-transfer-agent", "graph_id": "wire-transfer-agent", "display_name": "Wire Transfer Agent"},
92
  {"assistant_id": "telco-agent", "graph_id": "telco-agent", "display_name": "Telco Agent"},
 
93
  ]
94
  import requests
95
 
 
90
  {"assistant_id": "rbc-fees-agent", "graph_id": "rbc-fees-agent", "display_name": "RBC Fees Agent"},
91
  {"assistant_id": "wire-transfer-agent", "graph_id": "wire-transfer-agent", "display_name": "Wire Transfer Agent"},
92
  {"assistant_id": "telco-agent", "graph_id": "telco-agent", "display_name": "Telco Agent"},
93
+ {"assistant_id": "healthcare-agent", "graph_id": "healthcare-agent", "display_name": "Healthcare Nurse Agent"},
94
  ]
95
  import requests
96