scheduler / agent /nodes.py
umangchaudhry's picture
Upload 31 files
0d04b76 verified
Raw
History Blame Contribute Delete
16.5 kB
"""
LangGraph Node Functions — each function represents one step in the scheduling graph.
Each node takes the current SchedulingState and returns a partial state update (dict).
"""
from agent.state import SchedulingState
from core import data_manager, scheduling_engine, google_services
from core import settings_manager as settings
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
# Lazy-initialize the LLM to avoid import-time failures if the API key isn't set yet.
_llm = None
def _get_llm():
global _llm
if _llm is None:
from langchain_openai import ChatOpenAI
_llm = ChatOpenAI(model=settings.get_openai_model())
return _llm
def validate_inputs(state: SchedulingState) -> dict:
"""Validate all inputs from the dashboard before proceeding.
Checks:
- Patient exists
- Visit level is valid
- Address is not empty
- Preferred dates are at least 24 hours in the future
- Time window is valid (start < end, within reason)
Returns:
{"error": "message"} if validation fails, otherwise empty dict {}
"""
# Patient must exist
patient = data_manager.get_patient_by_id(state["patient_id"])
if not patient:
return {"error": f"Patient '{state['patient_id']}' not found."}
# Visit level must be in the configured set
if state["visit_level"] not in settings.get_visit_levels():
return {"error": f"Invalid visit level: {state['visit_level']}"}
# Visit address must not be blank
if not state.get("visit_address", "").strip():
return {"error": "Visit address is required."}
# At least one preferred date must be provided
if not state.get("preferred_dates"):
return {"error": "At least one preferred date is required."}
# Every preferred date must be at least the configured lead time from now.
# Lead time always applies, even for on-call providers.
# Compare in the configured timezone so a UTC server doesn't mis-evaluate local times.
lead_hours = settings.get_min_lead_time_hours()
now_local = datetime.now(ZoneInfo(settings.get_timezone())).replace(tzinfo=None)
min_allowed_dt = now_local + timedelta(hours=lead_hours)
window_start = state.get("preferred_time_window", {}).get("start", "08:00")
for date_str in state["preferred_dates"]:
try:
proposed_dt = datetime.strptime(f"{date_str} {window_start}", "%Y-%m-%d %H:%M")
except ValueError:
return {"error": f"Invalid date format: '{date_str}'. Expected YYYY-MM-DD."}
if proposed_dt < min_allowed_dt:
return {"error": f"Date {date_str} at {window_start} does not meet the {lead_hours}-hour lead time requirement."}
# Time window must have start before end
window = state.get("preferred_time_window", {})
w_start = window.get("start", "")
w_end = window.get("end", "")
if w_start and w_end:
if scheduling_engine.parse_time(w_start) >= scheduling_engine.parse_time(w_end):
return {"error": "Time window start must be before end."}
return {}
def check_availability(state: SchedulingState) -> dict:
"""Check which providers are available for the requested visit.
For each preferred date/time combo:
1. Get all active providers
2. Get each provider's existing appointments for that date
3. Run scheduling_engine.is_provider_available for each
Returns:
{"available_providers": [...]} — list of provider dicts with an added 'available_slots' field
If none available: {"available_providers": [], "error": "No providers available..."}
"""
# Skip if a previous node already set an error
if state.get("error"):
return {"available_providers": []}
all_providers = data_manager.get_all_providers(active_only=True)
visit_level = state["visit_level"]
visit_duration = scheduling_engine.get_visit_duration(visit_level)
window_start = state["preferred_time_window"]["start"]
window_end = state["preferred_time_window"]["end"]
available = []
for date in state["preferred_dates"]:
# Build a per-provider appointment index for this date (one read per provider)
appointments_by_provider = {
p["id"]: data_manager.get_appointments_for_provider(p["id"], date)
for p in all_providers
}
for provider in all_providers:
existing = appointments_by_provider[provider["id"]]
# Iterate through the time window in 30-minute increments
current_time = window_start
while scheduling_engine.parse_time(current_time) < scheduling_engine.parse_time(window_end):
if scheduling_engine.is_provider_available(
provider, date, current_time, visit_level, existing
):
end_time = scheduling_engine.add_minutes(current_time, visit_duration)
enriched = dict(provider)
enriched["available_date"] = date
enriched["available_start_time"] = current_time
enriched["available_end_time"] = end_time
enriched["is_oncall"] = scheduling_engine.is_oncall_for_slot(
provider, date, current_time, end_time
)
available.append(enriched)
current_time = scheduling_engine.add_minutes(current_time, 30)
if not available:
return {
"available_providers": [],
"error": (
"No providers are available for the requested dates and time. "
"Try different dates or a different time window."
),
}
return {"available_providers": available}
def calculate_travel(state: SchedulingState) -> dict:
"""Calculate travel times for all available providers to the patient's address.
For each available provider:
1. Determine their "origin" — the address of their previous appointment that day,
or their home address if it's their first visit
2. Call google_services.calculate_travel_time(origin, visit_address)
3. Also check if provider has other visits nearby (for area-affinity boost)
Returns:
{"travel_times": [...]} — provider dicts enriched with 'travel_time_minutes' and 'has_nearby_visits'
"""
visit_address = state["visit_address"]
enriched_providers = []
for provider in state["available_providers"]:
available_date = provider["available_date"]
available_start = provider["available_start_time"]
arrival_by = datetime.strptime(f"{available_date} {available_start}", "%Y-%m-%d %H:%M")
# Fetch existing appointments for this provider on the proposed date
existing = data_manager.get_appointments_for_provider(provider["id"], available_date)
# Origin = home address, or the last appointment address that ends before proposed start
origin = provider["address"]
for appt in sorted(existing, key=lambda a: a["end_time"]):
if appt["end_time"] <= available_start:
origin = appt["visit_address"]
# Travel time from origin to patient visit address (traffic-aware two-pass)
travel_result = google_services.calculate_travel_time(origin, visit_address, arrival_by)
travel_time = travel_result["travel_minutes"]
if travel_time < 0:
travel_time = 0 # Graceful fallback if the Maps API fails
recommended_departure = travel_result["recommended_departure"]
# Area affinity: does the provider have any visits close to the patient that day?
has_nearby = False
if existing:
appt_addresses = [a["visit_address"] for a in existing]
nearby_results = google_services.calculate_travel_times_batch(
visit_address, appt_addresses, arrival_by
)
# "Nearby" = within 15 minutes of the proposed visit address
has_nearby = any(r["travel_minutes"] >= 0 and r["travel_minutes"] <= 15 for r in nearby_results)
enriched = dict(provider)
enriched["travel_time_minutes"] = travel_time
enriched["has_nearby_visits"] = has_nearby
enriched["recommended_departure_time"] = recommended_departure.strftime("%H:%M")
enriched_providers.append(enriched)
return {"travel_times": enriched_providers}
def rank_and_present(state: SchedulingState) -> dict:
"""Rank available providers by travel time and area affinity, then format for display.
Uses scheduling_engine.rank_providers_by_travel.
Returns:
{"ranked_matches": [...]} — sorted list of match dicts with fields:
provider_id, provider_name, provider_email, date, start_time, end_time,
travel_time_minutes, has_nearby_visits, summary
"""
ranked = scheduling_engine.rank_providers_by_travel(state["travel_times"])
level_label = settings.get_visit_levels()[state["visit_level"]]["label"]
matches = []
for provider in ranked:
is_oncall = provider.get("is_oncall", False)
nearby_note = ", nearby visits" if provider["has_nearby_visits"] else ""
departure = provider.get("recommended_departure_time", "")
departure_note = f", depart by {departure}" if departure else ""
oncall_note = "[ON CALL] " if is_oncall else ""
summary = (
f"{oncall_note}{provider['name']}{level_label} on {provider['available_date']} "
f"at {provider['available_start_time']}{provider['available_end_time']} "
f"({provider['travel_time_minutes']} min travel{nearby_note}{departure_note})"
)
matches.append({
"provider_id": provider["id"],
"provider_name": provider["name"],
"provider_email": provider.get("email", ""),
"date": provider["available_date"],
"start_time": provider["available_start_time"],
"end_time": provider["available_end_time"],
"travel_time_minutes": provider["travel_time_minutes"],
"has_nearby_visits": provider["has_nearby_visits"],
"is_oncall": is_oncall,
"recommended_departure_time": departure,
"summary": summary,
})
return {"ranked_matches": matches}
def book_appointment(state: SchedulingState) -> dict:
"""Book the selected appointment.
Steps:
1. Final overlap check (in case something changed)
2. Create appointment record via data_manager.add_appointment
3. Create Google Calendar event via google_services.create_calendar_event
4. Update appointment record with google_event_id
5. Log action to audit log
Returns:
{"booked_appointment": {...}} or {"error": "Booking failed: ..."}
"""
selection = state["user_selection"]
provider_id = selection["provider_id"]
date = selection["date"]
start_time = selection["start_time"]
# Final freshness check: re-fetch the provider and their current appointments
provider = data_manager.get_provider_by_id(provider_id)
if not provider:
return {"error": f"Booking failed: provider '{provider_id}' not found."}
existing = data_manager.get_appointments_for_provider(provider_id, date)
if not scheduling_engine.is_provider_available(
provider, date, start_time, state["visit_level"], existing
):
return {"error": "Booking failed: the selected slot is no longer available."}
# Persist the appointment record
appointment_data = {
"patient_id": state["patient_id"],
"patient_name": state["patient_name"],
"provider_id": provider_id,
"provider_name": selection["provider_name"],
"date": date,
"start_time": start_time,
"end_time": selection["end_time"],
"visit_level": state["visit_level"],
"visit_address": state["visit_address"],
"recommended_departure_time": selection.get("recommended_departure_time", ""),
"status": "scheduled",
"google_event_id": None,
}
appointment = data_manager.add_appointment(appointment_data)
# Create Google Calendar event (non-critical — failure doesn't block booking)
try:
level_label = settings.get_visit_levels()[state["visit_level"]]["label"]
event_summary = f"[{level_label}] {state['patient_name']}{selection['provider_name']}"
description = (
f"Patient: {state['patient_name']}\n"
f"Provider: {selection['provider_name']}\n"
f"Visit Level: {level_label}\n"
f"Address: {state['visit_address']}"
)
event_id = google_services.create_calendar_event(
summary=event_summary,
description=description,
location=state["visit_address"],
start_datetime=f"{date}T{start_time}:00",
end_datetime=f"{date}T{selection['end_time']}:00",
)
data_manager.update_appointment(appointment["id"], {"google_event_id": event_id})
appointment["google_event_id"] = event_id
except Exception:
pass
# Audit log
data_manager.log_action(
user="admin",
action="SCHEDULE",
details=(
f"Scheduled {settings.get_visit_levels()[state['visit_level']]['label']} visit for "
f"{state['patient_name']} with {selection['provider_name']} "
f"on {date} at {start_time}"
),
appointment_id=appointment["id"],
)
return {"booked_appointment": appointment}
def send_notifications(state: SchedulingState) -> dict:
"""Send confirmation emails to both the provider and patient.
Uses google_services.send_scheduling_notification.
Returns:
{"notifications_sent": True/False}
"""
appointment = state.get("booked_appointment")
if not appointment:
return {"notifications_sent": False}
selection = state["user_selection"]
appointment_details = {
"patient_name": state["patient_name"],
"provider_name": selection["provider_name"],
"date": appointment["date"],
"start_time": appointment["start_time"],
"end_time": appointment["end_time"],
"visit_level": settings.get_visit_levels()[state["visit_level"]]["label"],
"address": state["visit_address"],
"recommended_departure_time": appointment.get("recommended_departure_time", ""),
}
try:
result = google_services.send_scheduling_notification(
provider_email=selection.get("provider_email", ""),
patient_email=state.get("patient_email", ""),
appointment_details=appointment_details,
)
sent = result.get("provider_sent", False) or result.get("patient_sent", False)
except Exception:
sent = False
return {"notifications_sent": sent}
def no_match(state: SchedulingState) -> dict:
"""Handle the case where no providers are available.
Uses the LLM to generate a helpful message suggesting alternative dates/times.
Returns:
{"error": "No matching providers found. <suggestion>"}
"""
existing_error = state.get("error", "No providers are available for the requested visit.")
try:
llm = _get_llm()
preferred_dates = state.get("preferred_dates", [])
visit_level = state.get("visit_level", "")
level_label = settings.get_visit_levels().get(visit_level, {}).get("label", str(visit_level))
window = state.get("preferred_time_window", {})
prompt = (
f"A home healthcare {level_label} visit could not be scheduled. "
f"Requested dates: {', '.join(preferred_dates)}. "
f"Requested time window: {window.get('start', '?')}{window.get('end', '?')}. "
f"Provide 2–3 brief, practical suggestions the scheduler can try next "
f"(e.g., different dates, expanded time window, or different visit level if appropriate). "
f"Keep the response to 2 sentences maximum."
)
response = llm.invoke(prompt)
suggestion = response.content.strip()
except Exception:
suggestion = (
"Try selecting different dates, expanding the time window, "
"or contacting the scheduling team for assistance."
)
return {"error": f"{existing_error} {suggestion}"}