""" KIA Agentic Tool System — v2.0 ================================ Real API integrations replacing mock tools. Each tool fetches LIVE data from external services. Tools: 1. Tactical Weather — Open-Meteo API (FREE, no key) 2. Marine Weather — Open-Meteo Marine API (FREE, no key) 3. DateTime Context — System clock (no API) 4. Defense News (GDELT) — GDELT Project (FREE, no key) 5. Defense News (GNews) — GNews API (FREE tier, needs key) 6. NATO RSS Feed — NATO.int RSS (FREE, no key) 7. Seismic Monitoring — USGS API (FREE, no key) 8. Exchange Rates — ExchangeRate API (FREE, no key) 9. Logistics DB — Internal mock (future: real DB) 10. Unit Tracker — Internal mock (future: real BFT) """ import os import re import time import logging import asyncio from datetime import datetime, timezone, timedelta from typing import List, Tuple, Dict, Optional from dataclasses import dataclass, field logger = logging.getLogger("Tools") # ====================================================================== # # ASYNC HTTP CLIENT # # ====================================================================== # try: import httpx _HTTP_AVAILABLE = True except ImportError: _HTTP_AVAILABLE = False logger.warning("httpx not installed. External API tools will be unavailable.") async def _fetch_json(url: str, timeout: float = 8.0, params: dict = None) -> dict: """Async HTTP GET returning JSON. Returns empty dict on failure.""" if not _HTTP_AVAILABLE: return {} try: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.get(url, params=params) resp.raise_for_status() return resp.json() except Exception as e: logger.warning(f"HTTP fetch failed for {url[:60]}: {e}") return {} async def _fetch_text(url: str, timeout: float = 8.0) -> str: """Async HTTP GET returning raw text.""" if not _HTTP_AVAILABLE: return "" try: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.get(url) resp.raise_for_status() return resp.text except Exception as e: logger.warning(f"HTTP fetch failed for {url[:60]}: {e}") return "" # ====================================================================== # # MILITARY LOCATIONS # # ====================================================================== # MILITARY_LOCATIONS = { "kuçovë": {"lat": 40.8003, "lon": 19.9167, "type": "bazë_ajrore", "name": "Baza Ajrore Kuçovë"}, "pashaliman": {"lat": 40.3667, "lon": 19.3833, "type": "bazë_detare", "name": "Baza Detare Pashaliman"}, "tiranë": {"lat": 41.3275, "lon": 19.8187, "type": "shtab", "name": "Shtabi i Përgjithshëm, Tiranë"}, "vlorë": {"lat": 40.4667, "lon": 19.4900, "type": "detar", "name": "Vlorë (Zonë Detare)"}, "porto romano": {"lat": 41.3333, "lon": 19.4167, "type": "port", "name": "Porti Ushtarak Porto Romano"}, "bizë": {"lat": 41.2414, "lon": 20.1058, "type": "poligon", "name": "Poligoni Ndërkombëtar i Bizës"}, "rubik": {"lat": 41.7800, "lon": 20.0000, "type": "depo", "name": "Depoja Ushtarake Rubik"}, "poliçan": {"lat": 40.6058, "lon": 20.0972, "type": "depo", "name": "Depoja e Municioneve Poliçan"}, "gramsh": {"lat": 40.8697, "lon": 20.1847, "type": "industrial", "name": "Qendra Industriale Gramsh"}, "shkozet": {"lat": 41.3167, "lon": 19.8000, "type": "industrial", "name": "Hub Industrial Shkozet"}, "shëngjin": {"lat": 41.8128, "lon": 19.5936, "type": "port", "name": "Porti i Shëngjinit"}, "durrës": {"lat": 41.3233, "lon": 19.4544, "type": "port", "name": "Porti i Durrësit"}, } # WMO Weather Code → Albanian description WMO_CODES = { 0: "E kthjellët", 1: "Kryesisht e kthjellët", 2: "Pjesërisht vranët", 3: "Vranësira", 45: "Mjegull", 48: "Mjegull me ngricë", 51: "Pika të lehta", 53: "Pika mesatare", 55: "Pika të dendura", 61: "Shi i lehtë", 63: "Shi mesatar", 65: "Shi i dendur", 71: "Borë e lehtë", 73: "Borë mesatare", 75: "Borë e dendur", 80: "Reshje shiu", 81: "Reshje mesatare", 82: "Reshje të forta", 95: "Stuhi me vetëtima", 96: "Stuhi me breshër", 99: "Stuhi e fortë me breshër", } # ====================================================================== # # TOOL 1: TACTICAL WEATHER (REAL) # # ====================================================================== # async def get_tactical_weather(location: str) -> str: """Real weather from Open-Meteo API for military locations.""" logger.info(f"TOOL: get_tactical_weather → {location}") # Find location loc_key = location.lower().strip() loc = None for key, data in MILITARY_LOCATIONS.items(): if key in loc_key or loc_key in key: loc = data loc_key = key break if not loc: # Fallback: use Tirana loc = MILITARY_LOCATIONS["tiranë"] loc_key = "tiranë" url = ( f"https://api.open-meteo.com/v1/forecast?" f"latitude={loc['lat']}&longitude={loc['lon']}" f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature," f"precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m" f"&hourly=temperature_2m,precipitation_probability,wind_speed_10m,visibility" f"&forecast_hours=24" f"&timezone=Europe/Tirane" ) data = await _fetch_json(url) if not data or "current" not in data: return _mock_weather(loc_key) # Fallback to mock if API fails current = data["current"] temp = current.get("temperature_2m", "N/A") humidity = current.get("relative_humidity_2m", "N/A") feels_like = current.get("apparent_temperature", "N/A") precip = current.get("precipitation", 0) weather_code = current.get("weather_code", 0) wind = current.get("wind_speed_10m", 0) wind_dir = current.get("wind_direction_10m", 0) gusts = current.get("wind_gusts_10m", 0) condition = WMO_CODES.get(weather_code, "E panjohur") # Calculate visibility from hourly data visibility_km = 20 # default hourly = data.get("hourly", {}) vis_list = hourly.get("visibility", []) if vis_list: visibility_km = vis_list[0] / 1000 # meters to km # Tactical flight assessment if wind > 45 or gusts > 60 or visibility_km < 3 or precip > 10: flight_status = "🔴 E KUQE — Fluturime të pezulluara" flight_detail = "Kushtet meteorologjike nuk lejojnë operacione ajrore" elif wind > 25 or gusts > 40 or visibility_km < 8 or precip > 3: flight_status = "🟡 E VERDHË — Kufizime operative" flight_detail = "Vetëm fluturime esenciale me autorizim të veçantë" else: flight_status = "🟢 E GJELBËR — Plotësisht operacionale" flight_detail = "Të gjitha operacionet ajrore të autorizuara" # Drone-specific assessment (TB2 limits: wind < 30 km/h for takeoff) if wind > 30 or gusts > 45: drone_status = "🔴 Dronët TB2: JOOPERACIONALË (erë e fortë)" elif wind > 20 or gusts > 35: drone_status = "🟡 Dronët TB2: KUFIZUAR (erë mesatare)" else: drone_status = "🟢 Dronët TB2: OPERACIONALË" # Wind direction in compass compass_dirs = ["V", "VVL", "VL", "LVL", "L", "LJL", "JL", "JJL", "J", "JJP", "JP", "PJP", "P", "PVP", "VP", "VVP"] wind_compass = compass_dirs[int((wind_dir + 11.25) / 22.5) % 16] text_report = ( f"[SISTEMI METEOROLOGJIK USHTARAK — RAPORT LIVE]\n" f"📍 Vendndodhja: {loc['name']} ({loc_key.upper()})\n" f"🕐 Koha: {datetime.now(timezone(timedelta(hours=2))).strftime('%H:%M %d/%m/%Y')} CET\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" f"- Kushtet: {condition}\n" f"- Temperatura: {temp}°C (ndihet si {feels_like}°C)\n" f"- Lagështia: {humidity}%\n" f"- Reshje: {precip} mm\n" f"- Era: {wind} km/h nga {wind_compass} (rafale deri {gusts} km/h)\n" f"- Dukshmëria: {visibility_km:.0f} km\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" f"✈️ Statusi Ajror: {flight_status}\n" f" {flight_detail}\n" f"🛸 {drone_status}\n" ) # Extract 24h temperature trend for Chart.js temp_trend = hourly.get("temperature_2m", [])[:24] widget_data = { "type": "weather", "data": { "location": loc['name'], "temp": temp, "feels_like": feels_like, "humidity": humidity, "condition": condition, "wind": wind, "wind_dir": wind_compass, "status": "E GJELBËR" if "E GJELBËR" in flight_status else ("E KUQE" if "E KUQE" in flight_status else "E VERDHË"), "precip": precip, "hourly_temps": temp_trend } } return (text_report, widget_data) def _mock_weather(location: str): """Fallback mock if Open-Meteo API is unreachable.""" text_report = ( f"[SISTEMI METEOROLOGJIK — RAPORT (OFFLINE MODE)]\n" f"📍 Vendndodhja: {location.upper()}\n" f"⚠️ API e motit e paarritshme. Të dhëna nga buferimi i fundit.\n" f"- Kushtet: Të paverifikuara\n" f"- Statusi: Kontrolloni burime alternative\n" ) return (text_report, None) # ====================================================================== # # TOOL 2: MARINE WEATHER (REAL) # # ====================================================================== # async def get_marine_weather(location: str) -> str: """Real marine/sea conditions from Open-Meteo Marine API.""" logger.info(f"TOOL: get_marine_weather → {location}") # Map to maritime locations maritime_coords = { "pashaliman": {"lat": 40.37, "lon": 19.38, "name": "Pashaliman (Deti Jon)"}, "vlorë": {"lat": 40.47, "lon": 19.30, "name": "Vlorë (Deti Jon)"}, "durrës": {"lat": 41.32, "lon": 19.45, "name": "Durrës (Deti Adriatik)"}, "porto romano":{"lat": 41.33, "lon": 19.42, "name": "Porto Romano (Deti Adriatik)"}, "shëngjin": {"lat": 41.81, "lon": 19.59, "name": "Shëngjin (Deti Adriatik)"}, "egje": {"lat": 39.50, "lon": 24.00, "name": "Deti Egje (Misioni Detar)"}, } loc_key = location.lower().strip() coords = None for key, data in maritime_coords.items(): if key in loc_key or loc_key in key: coords = data break if not coords: # If no explicit maritime location found, do not default. # Better to return empty than to provide irrelevant data. return "" url = ( f"https://marine-api.open-meteo.com/v1/marine?" f"latitude={coords['lat']}&longitude={coords['lon']}" f"¤t=wave_height,wave_direction,wave_period," f"wind_wave_height,swell_wave_height" f"&hourly=wave_height,wave_period" f"&forecast_hours=24" f"&timezone=Europe/Tirane" ) data = await _fetch_json(url) if not data or "current" not in data: return f"[DETAR] Të dhënat detare nuk janë të disponueshme momentalisht për {coords['name']}.\n" current = data["current"] wave_h = current.get("wave_height", 0) wave_dir = current.get("wave_direction", 0) wave_period = current.get("wave_period", 0) wind_wave = current.get("wind_wave_height", 0) swell = current.get("swell_wave_height", 0) # Sea state assessment (Douglas Scale) if wave_h < 0.5: sea_state = "🟢 Det i qetë (Shkalla 1-2)" nav_status = "Navigim i lirë për të gjitha mjetet" elif wave_h < 1.25: sea_state = "🟢 Det me valë të lehta (Shkalla 3)" nav_status = "Navigim normal" elif wave_h < 2.5: sea_state = "🟡 Det me valë mesatare (Shkalla 4)" nav_status = "Kujdes për mjetet e vogla" elif wave_h < 4.0: sea_state = "🟡 Det me valë të forta (Shkalla 5)" nav_status = "Vetëm anije me tonazhë mbi 500T" else: sea_state = "🔴 Det i trazuar (Shkalla 6+)" nav_status = "Operacione detare të pezulluara" return ( f"[SISTEMI DETAR — KUSHTET MARITIME LIVE]\n" f"📍 Zona: {coords['name']}\n" f"🕐 Koha: {datetime.now(timezone(timedelta(hours=2))).strftime('%H:%M %d/%m/%Y')} CET\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" f"- Lartësia e valëve: {wave_h} m\n" f"- Perioda e valëve: {wave_period} s\n" f"- Valë nga era: {wind_wave} m\n" f"- Swell: {swell} m\n" f"- Drejtimi i valëve: {wave_dir}°\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" f"🚢 Gjendja e Detit: {sea_state}\n" f" {nav_status}\n" ) # ====================================================================== # # TOOL 3: DATETIME CONTEXT # # ====================================================================== # def get_datetime_context() -> str: """Current date/time context for temporal awareness.""" now = datetime.now(timezone(timedelta(hours=2))) # CET day_names = { "Monday": "E Hënë", "Tuesday": "E Martë", "Wednesday": "E Mërkurë", "Thursday": "E Enjte", "Friday": "E Premte", "Saturday": "E Shtunë", "Sunday": "E Diel" } month_names = { 1: "Janar", 2: "Shkurt", 3: "Mars", 4: "Prill", 5: "Maj", 6: "Qershor", 7: "Korrik", 8: "Gusht", 9: "Shtator", 10: "Tetor", 11: "Nëntor", 12: "Dhjetor" } day_name = day_names.get(now.strftime("%A"), now.strftime("%A")) month_name = month_names.get(now.month, str(now.month)) return ( f"[KONTEKSTI KOHOR]\n" f"- Data: {now.day} {month_name} {now.year}\n" f"- Dita: {day_name}\n" f"- Ora: {now.strftime('%H:%M')} CET (Ora e Tiranës)\n" ) # ====================================================================== # # TOOL 4: DEFENSE NEWS — GDELT (FREE, no key) # # ====================================================================== # async def get_defense_news_gdelt(topic: str) -> str: """Fetch recent defense/geopolitical events from GDELT Project.""" logger.info(f"TOOL: get_defense_news_gdelt → {topic}") query_map = { "nato": "NATO Albania", "kosovë": "Kosovo KFOR", "kfor": "KFOR Kosovo peacekeeping", "ballkan": "Western Balkans security", "mbrojtje": "Albania defense military", "ushtri": "Albanian armed forces", "shqipëri": "Albania", "egje": "Aegean Sea security", } # Find best matching query search_term = "Albania military" topic_lower = topic.lower() for key, query in query_map.items(): if key in topic_lower: search_term = query break url = ( f"https://api.gdeltproject.org/api/v2/doc/doc?" f"query={search_term.replace(' ', '%20')}" f"&mode=ArtList&maxrecords=5&format=json" f"×pan=7d&sort=DateDesc" ) data = await _fetch_json(url, timeout=10) articles = data.get("articles", []) if not articles: return "" result = ( f"[BULETINI I INTELIGJENCËS — LAJME TË FUNDIT]\n" f"📡 Burim: GDELT Global Event Monitor\n" f"🔍 Kërkim: \"{search_term}\"\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" ) for i, article in enumerate(articles[:4], 1): title = article.get("title", "Pa titull") source = article.get("domain", "") date = article.get("seendate", "")[:10] url_link = article.get("url", "") result += f"{i}. [{date}] {title}\n Burim: {source}\n" return result # ====================================================================== # # TOOL 5: DEFENSE NEWS — GNews (needs API key) # # ====================================================================== # GNEWS_API_KEY = os.getenv("GNEWS_API_KEY", "") async def get_defense_news_gnews(topic: str) -> str: """Fetch Albanian defense news from GNews API.""" if not GNEWS_API_KEY: return "" # Silently skip if no key logger.info(f"TOOL: get_defense_news_gnews → {topic}") url = ( f"https://gnews.io/api/v4/search?" f"q={topic.replace(' ', '%20')}%20ushtri%20mbrojtje" f"&lang=sq&max=3&apikey={GNEWS_API_KEY}" ) data = await _fetch_json(url, timeout=10) articles = data.get("articles", []) if not articles: return "" result = ( f"[LAJME NGA MEDIA SHQIPTARE]\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" ) for article in articles[:3]: title = article.get("title", "") source = article.get("source", {}).get("name", "") pub = article.get("publishedAt", "")[:10] result += f"• [{pub}] {title} — {source}\n" return result # ====================================================================== # # TOOL 6: NATO RSS FEED (FREE, no key) # # ====================================================================== # async def get_nato_updates() -> str: """Fetch latest NATO news from RSS feed.""" logger.info("TOOL: get_nato_updates") # NATO news RSS url = "https://www.nato.int/cps/en/natohq/news.htm?query=Albania&date_from=&date_to=&sort_by=date&sort_dir=desc&max_items=5" # Use GDELT as NATO news proxy (more reliable than parsing RSS) data = await _fetch_json( "https://api.gdeltproject.org/api/v2/doc/doc?" "query=NATO%20official&mode=ArtList&maxrecords=4&format=json" "×pan=7d&sort=DateDesc&sourcelang=eng", timeout=10 ) articles = data.get("articles", []) if not articles: return "" result = ( f"[BULETINI NATO — ZHVILLIMET E FUNDIT]\n" f"📡 Monitorim automatik i lajmeve NATO\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" ) for i, article in enumerate(articles[:3], 1): title = article.get("title", "") date = article.get("seendate", "")[:10] result += f"{i}. [{date}] {title}\n" return result # ====================================================================== # # TOOL 7: SEISMIC MONITORING — USGS (FREE, no key) # # ====================================================================== # async def get_seismic_activity() -> str: """Fetch recent earthquakes near Albania from USGS.""" logger.info("TOOL: get_seismic_activity") # Albania bounding box + 200km buffer url = ( "https://earthquake.usgs.gov/fdsnws/event/1/query?" "format=geojson&limit=5&orderby=time" "&minlatitude=39.0&maxlatitude=43.0" "&minlongitude=18.0&maxlongitude=22.0" "&minmagnitude=2.5" ) data = await _fetch_json(url, timeout=10) features = data.get("features", []) if not features: return ( f"[MONITORIMI SIZMIK — USGS]\n" f"✅ Asnjë aktivitet sizmik i rëndësishëm në 30 ditët e fundit në rajonin e Shqipërisë.\n" ) result = ( f"[MONITORIMI SIZMIK — TË DHËNA LIVE USGS]\n" f"📍 Rajoni: Shqipëria dhe rrethina (39°-43°V, 18°-22°L)\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" ) for eq in features[:4]: props = eq.get("properties", {}) mag = props.get("mag", 0) place = props.get("place", "E panjohur") ts = props.get("time", 0) eq_time = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime("%d/%m %H:%M UTC") severity = "⚪" if mag < 3 else "🟡" if mag < 4 else "🟠" if mag < 5 else "🔴" result += f"{severity} M{mag:.1f} — {place}\n Koha: {eq_time}\n" return result # ====================================================================== # # TOOL 8: EXCHANGE RATES (FREE, no key) # # ====================================================================== # async def get_exchange_rates() -> str: """Fetch current LEK exchange rates.""" logger.info("TOOL: get_exchange_rates") url = "https://api.exchangerate-api.com/v4/latest/ALL" data = await _fetch_json(url, timeout=8) if not data or "rates" not in data: return ("", None) rates = data["rates"] eur = rates.get("EUR", 0) usd = rates.get("USD", 0) gbp = rates.get("GBP", 0) try_ = rates.get("TRY", 0) # Convert to "1 EUR = X ALL" format eur_to_all = round(1 / eur, 2) if eur else "N/A" usd_to_all = round(1 / usd, 2) if usd else "N/A" gbp_to_all = round(1 / gbp, 2) if gbp else "N/A" try_to_all = round(1 / try_, 2) if try_ else "N/A" text_report = ( f"[KURSI I KËMBIMIT — TË DHËNA LIVE]\n" f"🕐 {datetime.now(timezone(timedelta(hours=2))).strftime('%d/%m/%Y %H:%M')} CET\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" f"- 1 EUR = {eur_to_all} LEK\n" f"- 1 USD = {usd_to_all} LEK\n" f"- 1 GBP = {gbp_to_all} LEK\n" f"- 1 TRY = {try_to_all} LEK\n" ) widget_data = { "type": "exchange", "data": { "eur": eur_to_all, "usd": usd_to_all, "gbp": gbp_to_all, "try": try_to_all } } return (text_report, widget_data) # ====================================================================== # # TOOL 9: LOGISTICS (INTERNAL DB — mock) # # ====================================================================== # def query_logistics_db(query: str) -> str: """Internal logistics database query (mock — future: real DB).""" logger.info(f"TOOL: query_logistics_db → {query}") db = { "rubik": "Municion i lehtë (5.56mm): 1.25M fishekë | Gjendja: E Mirë | Kapaciteti: 85%", "poliçan": "Predha Mortaje (82mm): 45,000 njësi | Gjendja: E Mirë | Kapaciteti: 92%", "pashaliman": "Karburant Detar: 450 Ton | Pjesë Këmbimi (Anije Klasa Iliria): 3 Vite Rezervë", "kuçovë": "Karburant Aviacioni (Jet-A1): 2,100 Ton | Raketa MAM-L (TB2): 140 njësi", "tiranë": "Mjete të Blinduara (LMV2): 40 njësi (Gatishmëri 100%)", } query_lower = query.lower() for base, data in db.items(): if base in query_lower: return ( f"[SISTEMI QENDROR I LOGJISTIKËS — SKANIM]\n" f"- Baza: {base.capitalize()}\n" f"- Statusi i Inventarit: {data}\n" f"⚠️ Këto janë të dhëna demonstrative. Sistemi i vërtetë i logjistikës nuk është i lidhur.\n" ) return ( f"[SISTEMI QENDROR I LOGJISTIKËS — SKANIM]\n" "Nuk ka të dhëna specifike. Të gjitha depot raportojnë nivel gatishmërie mbi 80%.\n" "⚠️ Të dhëna demonstrative.\n" ) # ====================================================================== # # TOOL 10: UNIT TRACKER (INTERNAL — mock) # # ====================================================================== # def get_active_units(region: str) -> str: """Blue Force Tracking (mock — future: real BFT feed).""" logger.info(f"TOOL: get_active_units → {region}") return ( f"[SISTEMI BFT (Blue Force Tracker)]\n" f"- Rajoni i kërkuar: {region.upper()}\n" f"- Njësitë aktive: Informacion i kufizuar në këtë kanal\n" f"- Gatishmëria: Nivel i Lartë\n" f"- Koordinatat: Të Klasifikuara për transmetim të hapur.\n" f"⚠️ Të dhëna demonstrative. BFT real nuk është i lidhur.\n" ) # ====================================================================== # # RESPONSE CACHE # # ====================================================================== # _cache: Dict[str, Tuple[str, float]] = {} CACHE_TTL = { "weather": 600, # 10 min "marine": 600, # 10 min "news": 3600, # 1 hour "nato": 3600, # 1 hour "seismic": 1800, # 30 min "exchange": 43200, # 12 hours } def _get_cached(key: str, ttl: int) -> Optional[str]: """Get cached result if still valid.""" if key in _cache: result, timestamp = _cache[key] if time.time() - timestamp < ttl: return result return None def _set_cache(key: str, value: str): """Cache a result.""" _cache[key] = (value, time.time()) from typing import Optional, Dict, Tuple, List, Any def _match_any(keywords: List[str], text: str) -> bool: """Check if any keyword matches as a whole word (or prefix with boundary) in the text.""" for kw in keywords: # Use regex \b for word boundaries. # For Albanian, we also want to allow some suffixes, so we match \bkw # but the end boundary depends on the word. # Strict whole word is generally safer for intent detection. pattern = rf"\b{re.escape(kw)}\b" if re.search(pattern, text, re.I): return True return False # ====================================================================== # # MAIN EXECUTOR (ASYNC) # # ====================================================================== # async def execute_tools_async(query: str, role_level: int = 0) -> Tuple[str, List[Dict[str, Any]]]: """ Async tool executor with: - Intent detection via keywords - Parallel API calls - Response caching - Graceful fallback - Always includes datetime context """ query_lower = query.lower() tasks = [] task_names = [] # Always inject datetime context datetime_ctx = get_datetime_context() # 1. Weather Intent weather_locations_map = { "kuçovë": ["kucov", "kuçov", "kucovë", "kuçove", "kucove"], "tiranë": ["tiran"], "vlorë": ["vlor"], "pashaliman": ["pashaliman"], "porto romano": ["porto roman"], "rubik": ["rubik"], "bizë": ["biz"], "shëngjin": ["shengjin", "shëngjin"], "durrës": ["durres", "durrës"], "poliçan": ["polican", "poliçan"], "gramsh": ["gramsh"], "shkozet": ["shkozet"] } weather_keywords = ["moti", "mot", "klima", "erë", "erës", "dukshmëri", "ajrore", "fluturim", "temperatura", "reshje", "shi", "borë", "stuhi", "weather", "dron", "tb2"] if _match_any(weather_keywords, query_lower): loc = "tiranë" # Default for canonical, aliases in weather_locations_map.items(): if any(alias in query_lower for alias in aliases) or canonical in query_lower: loc = canonical break cache_key = f"weather:{loc}" cached = _get_cached(cache_key, CACHE_TTL["weather"]) if cached: async def return_cached(c=cached): return c tasks.append(return_cached()) else: tasks.append(get_tactical_weather(loc)) task_names.append(("weather", loc, cache_key)) # 2. Marine Weather Intent marine_keywords = ["det", "detar", "valë", "navigim", "anije", "maritime", "pashaliman", "porto romano", "egje", "adriatik", "jon"] if _match_any(marine_keywords, query_lower): loc = None # No default for l in ["pashaliman", "vlorë", "durrës", "porto romano", "shëngjin", "egje"]: if l in query_lower: loc = l break if loc: cache_key = f"marine:{loc}" cached = _get_cached(cache_key, CACHE_TTL["marine"]) if cached: async def return_cached(c=cached): return c tasks.append(return_cached()) else: tasks.append(get_marine_weather(loc)) task_names.append(("marine", loc, cache_key)) # 3. News / OSINT Intent news_keywords = ["lajm", "ngjarje", "zhvillim", "situatë", "çfarë po ndodh", "aktualitet", "buletini", "çfarë ndodhi", "news"] geopolitical_keywords = ["kosovë", "kfor", "ballkan", "egje", "nato", "rusi", "ukrain", "kinë", "iran"] if _match_any(news_keywords + geopolitical_keywords, query_lower): topic = query for kw in geopolitical_keywords: if kw in query_lower: topic = kw break cache_key = f"news:{topic}" cached = _get_cached(cache_key, CACHE_TTL["news"]) if cached: async def return_cached(c=cached): return c tasks.append(return_cached()) else: tasks.append(get_defense_news_gdelt(topic)) task_names.append(("news", topic, cache_key)) # Also try GNews if key is available if GNEWS_API_KEY: tasks.append(get_defense_news_gnews(topic)) task_names.append(("gnews", topic, None)) # 4. NATO-specific Intent nato_keywords = ["nato", "aleancë", "samit", "neni 5", "article 5"] if _match_any(nato_keywords, query_lower): cache_key = "nato:updates" cached = _get_cached(cache_key, CACHE_TTL["nato"]) if cached: async def return_cached(c=cached): return c tasks.append(return_cached()) else: tasks.append(get_nato_updates()) task_names.append(("nato", "updates", cache_key)) # 5. Seismic Intent seismic_keywords = ["tërmet", "sizmik", "lëkundje", "earthquake", "fatkeqësi natyrore"] if _match_any(seismic_keywords, query_lower): cache_key = "seismic:albania" cached = _get_cached(cache_key, CACHE_TTL["seismic"]) if cached: async def return_cached(c=cached): return c tasks.append(return_cached()) else: tasks.append(get_seismic_activity()) task_names.append(("seismic", "albania", cache_key)) # 6. Exchange Rate Intent exchange_keywords = ["kurs", "lekë", "lek", "euro", "dollar", "buxhet", "financ", "valutor", "këmbim"] if _match_any(exchange_keywords, query_lower): cache_key = "exchange:all" cached = _get_cached(cache_key, CACHE_TTL["exchange"]) if cached: async def return_cached(c=cached): return c tasks.append(return_cached()) else: tasks.append(get_exchange_rates()) task_names.append(("exchange", "ALL", cache_key)) # 7. Logistics Intent (sync, mock) logistics_keywords = ["municion", "depo", "logjistikë", "karburant", "inventar", "fishekë", "furnizim"] if _match_any(logistics_keywords, query_lower): async def logistics_wrapper(): return query_logistics_db(query) tasks.append(logistics_wrapper()) task_names.append(("logistics", "query", None)) # 8. Unit Tracker Intent (sync, mock) unit_keywords = ["trupa", "patrulla", "njësi aktive", "batalion", "ushtarë në terren", "vendosje", "bft"] if _match_any(unit_keywords, query_lower): async def units_wrapper(): return get_active_units("Terren Kombëtar") tasks.append(units_wrapper()) task_names.append(("units", "national", None)) # Execute all matched tools in parallel tool_output = "" active_widgets = [] if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) for i, result in enumerate(results): if isinstance(result, Exception): logger.warning(f"Tool {task_names[i][0]} failed: {result}") continue if result: widget = None if isinstance(result, tuple): res_text, widget = result else: res_text = result # Cache the result (store full result to preserve widget data) if i < len(task_names) and task_names[i][2]: _set_cache(task_names[i][2], result) tool_output += res_text + "\n" if widget: active_widgets.append(widget) if tool_output: tools_used = [t[0] for t in task_names if t[0] not in ("gnews",)] formatted_output = ( f"{datetime_ctx}\n" f"*** TË DHËNA LIVE NGA SISTEMET E INTELIGJENCËS ***\n" f"[Mjete të aktivizuara: {', '.join(tools_used)}]\n\n" f"{tool_output}" f"*** Përdor këto të dhëna live për saktësi maksimale. ***\n" ) return (formatted_output, active_widgets) # Even if no tools match, inject datetime return (f"{datetime_ctx}\n", []) def execute_tools(query: str): """ Sync wrapper for backward compatibility with existing api.py. Runs the async executor in a new event loop if needed. """ try: loop = asyncio.get_event_loop() if loop.is_running(): # We're inside an async context — create a task import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(asyncio.run, execute_tools_async(query)) return future.result(timeout=15) else: return loop.run_until_complete(execute_tools_async(query)) except Exception as e: logger.warning(f"Tool execution failed: {e}") # Return at least datetime context return get_datetime_context() + "\n" # ====================================================================== # # TOOL STATUS (for health check) # # ====================================================================== # def get_tools_status() -> dict: """Return status of all tools for health/dashboard endpoint.""" return { "total_tools": 10, "real_api_tools": 8, "mock_tools": 2, "tools": [ {"name": "Tactical Weather", "source": "Open-Meteo", "status": "live", "cost": "free"}, {"name": "Marine Weather", "source": "Open-Meteo Marine","status": "live", "cost": "free"}, {"name": "DateTime Context", "source": "System Clock", "status": "live", "cost": "free"}, {"name": "Defense News", "source": "GDELT Project", "status": "live", "cost": "free"}, {"name": "Albanian News", "source": "GNews API", "status": "live" if GNEWS_API_KEY else "no_key", "cost": "free"}, {"name": "NATO Updates", "source": "GDELT/NATO", "status": "live", "cost": "free"}, {"name": "Seismic Monitor", "source": "USGS", "status": "live", "cost": "free"}, {"name": "Exchange Rates", "source": "ExchangeRate-API", "status": "live", "cost": "free"}, {"name": "Logistics DB", "source": "Internal (Mock)", "status": "mock", "cost": "free"}, {"name": "Blue Force Track", "source": "Internal (Mock)", "status": "mock", "cost": "free"}, ], "http_available": _HTTP_AVAILABLE, "cache_entries": len(_cache), }