kia-command-center / app /tools.py
kiafa's picture
Premium UI/UX Overhaul & Optimization Update
633633c verified
Raw
History Blame Contribute Delete
36.7 kB
"""
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"&current=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"&current=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"&timespan=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"
"&timespan=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),
}