Spaces:
Running
Running
Commit
·
59ba41d
1
Parent(s):
b7f4c65
Added the nurse agent
Browse files- examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.1.pckl +0 -0
- examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.2.pckl +0 -0
- examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_checkpoint.3.pckl +0 -0
- examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_ops.pckl +0 -0
- examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/.langgraph_retry_counter.pckl +0 -0
- examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/store.pckl +0 -0
- examples/voice_agent_webrtc_langgraph/agents/.langgraph_api/store.vectors.pckl +0 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/README.md +51 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/__init__.py +9 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/logic.py +622 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/patients.json +110 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/pharmacies.json +16 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/providers.json +26 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/mock_data/triage_rules.json +54 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/prompts.py +31 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/react_agent.py +394 -0
- examples/voice_agent_webrtc_langgraph/agents/healthcare-agent/tools.py +167 -0
- examples/voice_agent_webrtc_langgraph/agents/langgraph.json +2 -1
- 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 |
|