Spaces:
Sleeping
Sleeping
| """ | |
| 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), | |
| } | |