Update src/api/agent.py
Browse files- src/api/agent.py +76 -87
src/api/agent.py
CHANGED
|
@@ -408,61 +408,24 @@ Respond ONLY with valid JSON. No preamble, no markdown fences.
|
|
| 408 |
# Hours TTL: 60 min (hours don't change intra-day)
|
| 409 |
# Fallback: library.ku.ac.ae/hours link if API is unreachable.
|
| 410 |
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
-
# In-process
|
| 416 |
-
_libcal_token_cache: Dict[str, Any] = {} # {"token": str, "expires_at": float}
|
| 417 |
_libcal_hours_cache: Dict[str, Any] = {} # {"data": dict, "cached_at": float}
|
| 418 |
-
|
| 419 |
-
_LIBCAL_HOURS_TTL = 60 * 60 # seconds
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
async def _libcal_get_token() -> Optional[str]:
|
| 423 |
-
"""
|
| 424 |
-
Fetch or return a cached OAuth2 client-credentials token for the LibCal API.
|
| 425 |
-
Credentials come from LIBCAL_CLIENT_ID / LIBCAL_CLIENT_SECRET env vars.
|
| 426 |
-
"""
|
| 427 |
-
now = time.time()
|
| 428 |
-
cached = _libcal_token_cache.get("token")
|
| 429 |
-
expires = _libcal_token_cache.get("expires_at", 0)
|
| 430 |
-
if cached and now < expires:
|
| 431 |
-
return cached
|
| 432 |
-
|
| 433 |
-
client_id = os.environ.get("LIBCAL_CLIENT_ID", "").strip()
|
| 434 |
-
client_secret = os.environ.get("LIBCAL_CLIENT_SECRET", "").strip()
|
| 435 |
-
if not client_id or not client_secret:
|
| 436 |
-
logger.warning("LibCal: LIBCAL_CLIENT_ID / LIBCAL_CLIENT_SECRET not set")
|
| 437 |
-
return None
|
| 438 |
-
|
| 439 |
-
try:
|
| 440 |
-
async with httpx.AsyncClient(timeout=10) as client:
|
| 441 |
-
r = await client.post(
|
| 442 |
-
f"{_LIBCAL_BASE}/api/1.1/oauth/token",
|
| 443 |
-
data={
|
| 444 |
-
"client_id": client_id,
|
| 445 |
-
"client_secret": client_secret,
|
| 446 |
-
"grant_type": "client_credentials",
|
| 447 |
-
},
|
| 448 |
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 449 |
-
)
|
| 450 |
-
r.raise_for_status()
|
| 451 |
-
token = r.json().get("access_token")
|
| 452 |
-
if token:
|
| 453 |
-
_libcal_token_cache["token"] = token
|
| 454 |
-
_libcal_token_cache["expires_at"] = now + _LIBCAL_TOKEN_TTL
|
| 455 |
-
logger.info("LibCal: token refreshed")
|
| 456 |
-
return token
|
| 457 |
-
except Exception as exc:
|
| 458 |
-
logger.warning("LibCal token fetch failed: %s", exc)
|
| 459 |
-
return None
|
| 460 |
|
| 461 |
|
| 462 |
async def _libcal_fetch_hours() -> Optional[dict]:
|
| 463 |
"""
|
| 464 |
-
Fetch today + next 6 days of hours
|
| 465 |
-
|
|
|
|
| 466 |
Caches result for _LIBCAL_HOURS_TTL seconds.
|
| 467 |
"""
|
| 468 |
now = time.time()
|
|
@@ -470,70 +433,96 @@ async def _libcal_fetch_hours() -> Optional[dict]:
|
|
| 470 |
if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
|
| 471 |
return _libcal_hours_cache["data"]
|
| 472 |
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
| 475 |
return None
|
| 476 |
|
| 477 |
-
# UAE is UTC+4
|
| 478 |
from datetime import timezone, timedelta
|
| 479 |
-
uae_now
|
| 480 |
date_from = uae_now.strftime("%Y-%m-%d")
|
| 481 |
date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
|
| 482 |
|
| 483 |
-
combined: dict = {}
|
| 484 |
try:
|
| 485 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
_libcal_hours_cache["data"] = combined
|
| 500 |
-
_libcal_hours_cache["cached_at"] = now
|
| 501 |
-
logger.info("LibCal: hours refreshed for %d locations", len(combined))
|
| 502 |
-
return combined
|
| 503 |
-
|
| 504 |
except Exception as exc:
|
| 505 |
logger.warning("LibCal hours fetch failed: %s", exc)
|
| 506 |
return None
|
| 507 |
|
| 508 |
|
| 509 |
def _parse_libcal_time(raw: str) -> str:
|
| 510 |
-
"""Normalise LibCal time strings: '
|
|
|
|
|
|
|
| 511 |
raw = raw.strip().replace("\u2013", "-").replace("β", "-")
|
| 512 |
raw = re.sub(r"\s+", "", raw)
|
| 513 |
-
|
|
|
|
| 514 |
return raw
|
| 515 |
|
| 516 |
|
| 517 |
def _format_libcal_day(day_data: dict) -> str:
|
| 518 |
-
"""
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
if status == "24hours":
|
| 524 |
return "Open 24 hours"
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
if opens and closes:
|
| 528 |
return f"{_parse_libcal_time(opens)} β {_parse_libcal_time(closes)}"
|
| 529 |
-
|
| 530 |
-
times
|
|
|
|
| 531 |
if times and isinstance(times, list):
|
| 532 |
first = times[0]
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
if
|
| 536 |
-
return f"{_parse_libcal_time(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
return "See hours page"
|
| 538 |
|
| 539 |
|
|
|
|
| 408 |
# Hours TTL: 60 min (hours don't change intra-day)
|
| 409 |
# Fallback: library.ku.ac.ae/hours link if API is unreachable.
|
| 410 |
|
| 411 |
+
# LibCal hours are fetched via the Cloudflare Worker (/hours endpoint).
|
| 412 |
+
# The Worker holds the LibCal credentials and is not blocked by LibCal's
|
| 413 |
+
# host allowlist. Direct calls from HF Spaces return 403.
|
| 414 |
+
#
|
| 415 |
+
# Worker secrets required: LIBCAL_CLIENT_ID, LIBCAL_CLIENT_SECRET
|
| 416 |
+
# Worker endpoint: {CLOUDFLARE_WORKER_URL}/hours
|
| 417 |
+
# Location IDs (reference): Main Campus=6386, Habshan=25028
|
| 418 |
|
| 419 |
+
# In-process hours cache β avoids hitting the worker on every hours query
|
|
|
|
| 420 |
_libcal_hours_cache: Dict[str, Any] = {} # {"data": dict, "cached_at": float}
|
| 421 |
+
_LIBCAL_HOURS_TTL = 60 * 60 # 60 minutes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
|
| 424 |
async def _libcal_fetch_hours() -> Optional[dict]:
|
| 425 |
"""
|
| 426 |
+
Fetch today + next 6 days of hours via Cloudflare Worker proxy.
|
| 427 |
+
The Worker calls LibCal on our behalf (not blocked by LibCal allowlist).
|
| 428 |
+
Returns dict: {"main_campus": {...}, "habshan": {...}} or None on failure.
|
| 429 |
Caches result for _LIBCAL_HOURS_TTL seconds.
|
| 430 |
"""
|
| 431 |
now = time.time()
|
|
|
|
| 433 |
if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
|
| 434 |
return _libcal_hours_cache["data"]
|
| 435 |
|
| 436 |
+
settings = get_settings()
|
| 437 |
+
worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
|
| 438 |
+
if not worker_url:
|
| 439 |
+
logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set β cannot fetch hours")
|
| 440 |
return None
|
| 441 |
|
|
|
|
| 442 |
from datetime import timezone, timedelta
|
| 443 |
+
uae_now = datetime.now(timezone(timedelta(hours=4)))
|
| 444 |
date_from = uae_now.strftime("%Y-%m-%d")
|
| 445 |
date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
|
| 446 |
|
|
|
|
| 447 |
try:
|
| 448 |
async with httpx.AsyncClient(timeout=10) as client:
|
| 449 |
+
r = await client.get(
|
| 450 |
+
f"{worker_url.rstrip('/')}/hours",
|
| 451 |
+
params={"from": date_from, "to": date_to},
|
| 452 |
+
)
|
| 453 |
+
r.raise_for_status()
|
| 454 |
+
data = r.json()
|
| 455 |
+
if data.get("error"):
|
| 456 |
+
logger.warning("LibCal worker error: %s", data["error"])
|
| 457 |
+
return None
|
| 458 |
+
_libcal_hours_cache["data"] = data
|
| 459 |
+
_libcal_hours_cache["cached_at"] = now
|
| 460 |
+
logger.info("LibCal: hours refreshed via worker")
|
| 461 |
+
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
except Exception as exc:
|
| 463 |
logger.warning("LibCal hours fetch failed: %s", exc)
|
| 464 |
return None
|
| 465 |
|
| 466 |
|
| 467 |
def _parse_libcal_time(raw: str) -> str:
|
| 468 |
+
"""Normalise LibCal time strings: '8:00am' β '8:00 AM', '7:30am' β '7:30 AM'."""
|
| 469 |
+
if not raw:
|
| 470 |
+
return raw
|
| 471 |
raw = raw.strip().replace("\u2013", "-").replace("β", "-")
|
| 472 |
raw = re.sub(r"\s+", "", raw)
|
| 473 |
+
# Insert space before AM/PM
|
| 474 |
+
raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
|
| 475 |
return raw
|
| 476 |
|
| 477 |
|
| 478 |
def _format_libcal_day(day_data: dict) -> str:
|
| 479 |
+
"""
|
| 480 |
+
Return a human-readable hours string for one day entry from LibCal API v1.1.
|
| 481 |
+
|
| 482 |
+
LibCal returns several possible structures:
|
| 483 |
+
Structure A: {"status": "open", "hours": [{"from": "8:00am", "to": "5:00pm"}]}
|
| 484 |
+
Structure B: {"status": "open", "opens": "8:00am", "closes": "5:00pm"}
|
| 485 |
+
Structure C: {"status": "closed", "note": "Eid Al Adha"}
|
| 486 |
+
Structure D: {"status": "24hours"}
|
| 487 |
+
"""
|
| 488 |
+
if not day_data or not isinstance(day_data, dict):
|
| 489 |
+
return "See hours page"
|
| 490 |
+
|
| 491 |
+
status = str(day_data.get("status") or "").lower().strip()
|
| 492 |
+
note = str(day_data.get("note") or "").strip()
|
| 493 |
+
|
| 494 |
+
if status in ("closed", "0", "false"):
|
| 495 |
+
return f"Closed{f' β {note}' if note else ''}"
|
| 496 |
if status == "24hours":
|
| 497 |
return "Open 24 hours"
|
| 498 |
+
|
| 499 |
+
# Structure A β hours array (LibCal API v1.1 standard)
|
| 500 |
+
hours_list = day_data.get("hours") or []
|
| 501 |
+
if hours_list and isinstance(hours_list, list):
|
| 502 |
+
first = hours_list[0]
|
| 503 |
+
f = first.get("from") or first.get("opens") or ""
|
| 504 |
+
t = first.get("to") or first.get("closes") or ""
|
| 505 |
+
if f and t:
|
| 506 |
+
return f"{_parse_libcal_time(f)} β {_parse_libcal_time(t)}"
|
| 507 |
+
|
| 508 |
+
# Structure B β opens/closes at top level
|
| 509 |
+
opens = day_data.get("opens") or day_data.get("open") or ""
|
| 510 |
+
closes = day_data.get("closes") or day_data.get("close") or ""
|
| 511 |
if opens and closes:
|
| 512 |
return f"{_parse_libcal_time(opens)} β {_parse_libcal_time(closes)}"
|
| 513 |
+
|
| 514 |
+
# Structure C β times list (some LibCal versions)
|
| 515 |
+
times = day_data.get("times") or []
|
| 516 |
if times and isinstance(times, list):
|
| 517 |
first = times[0]
|
| 518 |
+
f = first.get("from") or first.get("opens") or ""
|
| 519 |
+
t = first.get("to") or first.get("closes") or ""
|
| 520 |
+
if f and t:
|
| 521 |
+
return f"{_parse_libcal_time(f)} β {_parse_libcal_time(t)}"
|
| 522 |
+
|
| 523 |
+
if status in ("open",):
|
| 524 |
+
return "Open β see hours page for times"
|
| 525 |
+
|
| 526 |
return "See hours page"
|
| 527 |
|
| 528 |
|