""" Tavily-backed opportunities aggregator for ALU students. Rotates between a small set of queries and caches results in memory so the free tier (~1,000 searches/month) lasts. Falls back gracefully if Tavily is not configured or fails — the frontend has its own curated list as a final backstop. """ import os import time import hashlib import logging import random from typing import List, Dict, Any, Optional from urllib.parse import urlparse logger = logging.getLogger(__name__) CACHE_TTL_SECONDS = 30 * 60 # 30 minutes — Tavily costs per call, so cache hard. MAX_RESULTS_PER_QUERY = 10 MAX_RETURNED = 30 # We aggregate across queries and return up to this many. # Queries we rotate through. Picked to surface different categories without # being too narrow. QUERY_POOL = [ "scholarships for African university students 2026", "internships for African students in technology", "fellowships for young African leaders", "competitions and grants for African undergraduate students", "graduate programs for African students with funding", "Mastercard Foundation scholarships Africa students", "remote internships open to African students", "leadership programs for young Africans", ] # Heuristic keyword → category mapping. Cheap and good enough; the frontend # only uses this for the colored pill. CATEGORY_KEYWORDS = [ ("Scholarship", ["scholarship", "scholar", "bursary", "tuition"]), ("Internship", ["internship", "intern"]), ("Fellowship", ["fellowship", "fellow"]), ("Competition", ["competition", "challenge", "hackathon", "prize", "contest"]), ("Grant", ["grant", "funding", "seed capital"]), ("Program", ["programme", "program", "training", "bootcamp", "course"]), ] def _classify(title: str, content: str) -> str: blob = f"{title} {content}".lower() for category, keywords in CATEGORY_KEYWORDS: if any(k in blob for k in keywords): return category return "Program" def _extract_org(url: str, title: str) -> str: """Best-effort organization name from the URL host or title.""" try: host = urlparse(url).netloc # Strip common prefixes. for prefix in ("www.", "blog.", "apply."): if host.startswith(prefix): host = host[len(prefix):] return host except Exception: return title.split("|")[-1].strip() if "|" in title else "Various" def _opportunity_id(url: str) -> str: return hashlib.md5(url.encode("utf-8")).hexdigest()[:12] class OpportunitiesService: """Tavily-backed opportunities aggregator with in-memory cache.""" def __init__(self): self.api_key = os.getenv("TAVILY_API_KEY", "") self.enabled = bool(self.api_key) self.client = None self._cache: Dict[str, Any] = {"fetched_at": 0.0, "opportunities": []} if not self.enabled: logger.info("[INFO] Opportunities service disabled (no TAVILY_API_KEY)") return try: from tavily import TavilyClient self.client = TavilyClient(api_key=self.api_key) logger.info("[OK] Opportunities service enabled (Tavily)") except ImportError: logger.warning("[!] tavily-python not installed — pip install tavily-python") self.enabled = False except Exception as e: logger.error(f"[FAIL] Failed to initialize Tavily: {e}") self.enabled = False def _cache_fresh(self) -> bool: return ( len(self._cache["opportunities"]) > 0 and time.time() - self._cache["fetched_at"] < CACHE_TTL_SECONDS ) def _search_one(self, query: str) -> List[Dict[str, Any]]: """Run a single Tavily query and map results to our Opportunity shape.""" try: res = self.client.search( query=query, search_depth="basic", max_results=MAX_RESULTS_PER_QUERY, include_answer=False, ) except Exception as e: logger.error(f"[FAIL] Tavily search failed for '{query}': {e}") return [] opps: List[Dict[str, Any]] = [] for r in res.get("results", []): url = r.get("url", "") title = (r.get("title") or "").strip() content = (r.get("content") or "").strip() if not url or not title: continue opps.append( { "id": _opportunity_id(url), "title": title[:140], "organization": _extract_org(url, title), "category": _classify(title, content), "description": content[:240], "url": url, "deadline": None, "location": None, } ) return opps def get_opportunities(self, force_refresh: bool = False) -> List[Dict[str, Any]]: """ Return a list of opportunity objects. Uses cache if fresh. Cache key is global (not per-user). Per-user variety comes from the frontend, which shuffles the returned list per session/page. """ if not self.enabled: return [] if not force_refresh and self._cache_fresh(): return self._cache["opportunities"] # Pick 2 queries at random from the pool — keeps things varied between # cache refreshes without burning the quota. chosen = random.sample(QUERY_POOL, k=min(2, len(QUERY_POOL))) aggregated: Dict[str, Dict[str, Any]] = {} for q in chosen: for opp in self._search_one(q): # Dedupe by id (URL hash) — different queries often return the # same flagship programs. aggregated.setdefault(opp["id"], opp) opportunities = list(aggregated.values())[:MAX_RETURNED] if opportunities: self._cache = {"fetched_at": time.time(), "opportunities": opportunities} return opportunities def get_status(self) -> Dict[str, Any]: return { "enabled": self.enabled, "api_key_set": bool(self.api_key), "client_initialized": self.client is not None, "cache_size": len(self._cache["opportunities"]), "cache_age_seconds": int(time.time() - self._cache["fetched_at"]) if self._cache["opportunities"] else None, } opportunities_service = OpportunitiesService()