nikeshn commited on
Commit
7c326ed
Β·
verified Β·
1 Parent(s): 43287b2

Update src/api/agent.py

Browse files
Files changed (1) hide show
  1. 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
- _LIBCAL_BASE = "https://kustar.libcal.com"
412
- _LIBCAL_LOCATION_IDS = {"main_campus": 6386, "habshan": 25028}
413
- _LIBCAL_TIMEZONE = "Asia/Dubai" # UTC+4
 
 
 
 
414
 
415
- # In-process token + hours caches
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
- _LIBCAL_TOKEN_TTL = 55 * 60 # seconds
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 for both locations.
465
- Returns a dict keyed by location name, with per-date hours.
 
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
- token = await _libcal_get_token()
474
- if not token:
 
 
475
  return None
476
 
477
- # UAE is UTC+4
478
  from datetime import timezone, timedelta
479
- uae_now = datetime.now(timezone(timedelta(hours=4)))
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
- for name, loc_id in _LIBCAL_LOCATION_IDS.items():
487
- r = await client.get(
488
- f"{_LIBCAL_BASE}/api/1.1/hours/{loc_id}",
489
- params={"from": date_from, "to": date_to},
490
- headers={"Authorization": f"Bearer {token}"},
491
- )
492
- r.raise_for_status()
493
- data = r.json()
494
- # LibCal returns a list of location objects; we want the first
495
- locations = data if isinstance(data, list) else [data]
496
- if locations:
497
- combined[name] = locations[0]
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: '8am' β†’ '8:00 AM', '7:30am' β†’ '7:30 AM'."""
 
 
511
  raw = raw.strip().replace("\u2013", "-").replace("–", "-")
512
  raw = re.sub(r"\s+", "", raw)
513
- raw = raw.replace("am", " AM").replace("pm", " PM")
 
514
  return raw
515
 
516
 
517
  def _format_libcal_day(day_data: dict) -> str:
518
- """Return a human-readable hours string for one day entry from LibCal."""
519
- status = (day_data.get("status") or "").lower()
520
- if status in ("closed", "0"):
521
- note = day_data.get("note", "")
522
- return f"Closed{f' ({note})' if note else ''}"
 
 
 
 
 
 
 
 
 
 
 
 
523
  if status == "24hours":
524
  return "Open 24 hours"
525
- opens = day_data.get("opens", "") or ""
526
- closes = day_data.get("closes", "") or ""
 
 
 
 
 
 
 
 
 
 
 
527
  if opens and closes:
528
  return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
529
- # Some LibCal responses put hours in a nested "times" list
530
- times = day_data.get("times", [])
 
531
  if times and isinstance(times, list):
532
  first = times[0]
533
- o = first.get("from", "") or first.get("opens", "")
534
- c = first.get("to", "") or first.get("closes", "")
535
- if o and c:
536
- return f"{_parse_libcal_time(o)} – {_parse_libcal_time(c)}"
 
 
 
 
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