Spaces:
Sleeping
Sleeping
Revision on the options
Browse files- app.py +447 -253
- brief.py +737 -552
- export.py +59 -30
- map_utils.py +16 -42
- tools.py +747 -618
app.py
CHANGED
|
@@ -1,253 +1,447 @@
|
|
| 1 |
-
"""
|
| 2 |
-
app.py β Multi-source OSINT Analyst Space
|
| 3 |
-
Agentic loop powered by smolagents + HuggingFace Inference API.
|
| 4 |
-
|
| 5 |
-
Required Space Secrets:
|
| 6 |
-
|
| 7 |
-
ACLED_EMAIL
|
| 8 |
-
HF_TOKEN
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
import
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
from
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
""
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
.
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
)
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
"""
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py β Multi-source OSINT Analyst Space
|
| 3 |
+
Agentic loop powered by smolagents + HuggingFace Inference API.
|
| 4 |
+
|
| 5 |
+
Required Space Secrets:
|
| 6 |
+
ACLED_USERNAME β from https://developer.acleddata.com
|
| 7 |
+
ACLED_EMAIL β email used to register for ACLED access
|
| 8 |
+
HF_TOKEN β HuggingFace token (set automatically in Spaces)
|
| 9 |
+
|
| 10 |
+
Optional Space Secrets:
|
| 11 |
+
AVWX_TOKEN β free token from https://avwx.rest (enables NOTAM lookups)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import time
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
import gradio as gr
|
| 20 |
+
from smolagents import InferenceClientModel, ToolCallingAgent
|
| 21 |
+
|
| 22 |
+
from tools import (
|
| 23 |
+
fetch_acled_events,
|
| 24 |
+
fetch_rss_headlines,
|
| 25 |
+
fetch_travel_advisory,
|
| 26 |
+
fetch_airspace_status,
|
| 27 |
+
list_available_sources,
|
| 28 |
+
)
|
| 29 |
+
from brief import (
|
| 30 |
+
BRIEF_PROMPT_SCHEMA,
|
| 31 |
+
ThreatBrief,
|
| 32 |
+
parse_brief_from_llm,
|
| 33 |
+
render_brief_html,
|
| 34 |
+
)
|
| 35 |
+
from export import generate_pdf
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Model + Agent setup
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
MODEL_ID = "Qwen/Qwen2.5-72B-Instruct"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_agent() -> ToolCallingAgent:
|
| 45 |
+
model = InferenceClientModel(
|
| 46 |
+
model_id=MODEL_ID,
|
| 47 |
+
token=os.environ.get("HF_TOKEN"),
|
| 48 |
+
timeout=90,
|
| 49 |
+
)
|
| 50 |
+
agent = ToolCallingAgent(
|
| 51 |
+
tools=[
|
| 52 |
+
fetch_acled_events,
|
| 53 |
+
fetch_rss_headlines,
|
| 54 |
+
fetch_travel_advisory,
|
| 55 |
+
fetch_airspace_status,
|
| 56 |
+
list_available_sources,
|
| 57 |
+
],
|
| 58 |
+
model=model,
|
| 59 |
+
max_steps=12,
|
| 60 |
+
verbosity_level=1,
|
| 61 |
+
)
|
| 62 |
+
return agent
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Core analysis function
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
SYSTEM_PROMPT = """You are a professional OSINT intelligence analyst specializing
|
| 70 |
+
in geopolitical conflict and security threat assessment. Your job is to:
|
| 71 |
+
|
| 72 |
+
1. Call the available tools to gather data from ACLED, RSS news sources,
|
| 73 |
+
State Department travel advisories, and airspace feeds.
|
| 74 |
+
2. Collect enough information to assess the security situation, including
|
| 75 |
+
travel safety and airspace status.
|
| 76 |
+
3. Synthesize your findings into a structured threat brief.
|
| 77 |
+
|
| 78 |
+
Always start by checking what sources are available if needed.
|
| 79 |
+
Be thorough β use multiple sources before drawing conclusions.
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def run_analysis(
|
| 84 |
+
country: str,
|
| 85 |
+
passport_country: str,
|
| 86 |
+
rss_sources: list,
|
| 87 |
+
days_back: int,
|
| 88 |
+
progress=gr.Progress(),
|
| 89 |
+
) -> tuple:
|
| 90 |
+
"""
|
| 91 |
+
Runs the agentic OSINT analysis loop and returns:
|
| 92 |
+
- Structured HTML threat brief
|
| 93 |
+
- Raw agent trace for transparency
|
| 94 |
+
- ThreatBrief object (stored in gr.State for PDF export)
|
| 95 |
+
"""
|
| 96 |
+
if not country or not str(country).strip():
|
| 97 |
+
return "<p style='color:red'>Please select a country or region.</p>", "", None
|
| 98 |
+
country = str(country).strip()
|
| 99 |
+
|
| 100 |
+
progress(0.1, desc="Initializing agent...")
|
| 101 |
+
|
| 102 |
+
sources_str = ",".join(rss_sources) if rss_sources else "bbc_world,al_jazeera"
|
| 103 |
+
include_embassy = bool(passport_country and passport_country != "Not specified")
|
| 104 |
+
|
| 105 |
+
embassy_instruction = (
|
| 106 |
+
f"4. REQUIRED β Populate the 'embassy' JSON field with the {passport_country} embassy "
|
| 107 |
+
f"or nearest consulate in '{country}'. Include: name, street address, main phone number, "
|
| 108 |
+
f"after-hours emergency phone, and official website URL.\n"
|
| 109 |
+
if include_embassy else ""
|
| 110 |
+
)
|
| 111 |
+
step_airspace = 5 if include_embassy else 4
|
| 112 |
+
step_analyse = step_airspace + 1
|
| 113 |
+
step_output = step_analyse + 1
|
| 114 |
+
|
| 115 |
+
task = f"""
|
| 116 |
+
Conduct an OSINT threat assessment for: {country}
|
| 117 |
+
{f"Traveller passport country: {passport_country}" if include_embassy else ""}
|
| 118 |
+
|
| 119 |
+
Instructions:
|
| 120 |
+
1. Fetch ACLED armed conflict events for '{country}' over the last {days_back} days.
|
| 121 |
+
2. Fetch recent RSS news headlines related to '{country}' from these sources: {sources_str}.
|
| 122 |
+
3. REQUIRED β Call fetch_travel_advisory for '{country}' and include the result in the travel_advisory fields.
|
| 123 |
+
{embassy_instruction}{step_airspace}. Fetch airspace status for '{country}' using the fetch_airspace_status tool.
|
| 124 |
+
{step_analyse}. Analyse all collected data carefully.
|
| 125 |
+
{step_output}. Produce your final output as ONLY a JSON threat brief matching this schema:
|
| 126 |
+
|
| 127 |
+
{BRIEF_PROMPT_SCHEMA}
|
| 128 |
+
|
| 129 |
+
Today's date: {datetime.utcnow().strftime('%Y-%m-%d')}
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
progress(0.2, desc="Agent gathering OSINT data...")
|
| 133 |
+
|
| 134 |
+
raw_output = None
|
| 135 |
+
last_error = None
|
| 136 |
+
for attempt in range(1, 4):
|
| 137 |
+
try:
|
| 138 |
+
agent = build_agent()
|
| 139 |
+
raw_output = agent.run(task, additional_args={"system_prompt": SYSTEM_PROMPT})
|
| 140 |
+
break
|
| 141 |
+
except Exception as e:
|
| 142 |
+
last_error = e
|
| 143 |
+
err_str = str(e).lower()
|
| 144 |
+
is_timeout = any(k in err_str for k in ("504", "timeout", "gateway", "timed out"))
|
| 145 |
+
if is_timeout and attempt < 3:
|
| 146 |
+
progress(0.2 + attempt * 0.1, desc=f"HF API timeout β retrying (attempt {attempt + 1}/3)...")
|
| 147 |
+
time.sleep(5 * attempt)
|
| 148 |
+
continue
|
| 149 |
+
error_html = f"""
|
| 150 |
+
<div style='padding:20px;background:#fff3f3;border:1px solid #cc0000;border-radius:8px'>
|
| 151 |
+
<strong>Analysis failed (attempt {attempt}/3):</strong><br>
|
| 152 |
+
<code style='font-size:0.85em'>{e}</code><br><br>
|
| 153 |
+
<em>If you see a 504 / gateway timeout, the HF Inference API is under heavy load.
|
| 154 |
+
Wait a minute and try again, or reduce the number of selected news sources.</em>
|
| 155 |
+
</div>
|
| 156 |
+
"""
|
| 157 |
+
return error_html, str(e), None
|
| 158 |
+
|
| 159 |
+
if raw_output is None:
|
| 160 |
+
return "<p style='color:red'>Analysis failed after 3 attempts. Please try again later.</p>", str(last_error), None
|
| 161 |
+
|
| 162 |
+
progress(0.85, desc="Parsing intelligence brief...")
|
| 163 |
+
|
| 164 |
+
if isinstance(raw_output, str):
|
| 165 |
+
brief = parse_brief_from_llm(raw_output)
|
| 166 |
+
else:
|
| 167 |
+
brief = ThreatBrief(
|
| 168 |
+
narrative_summary=str(raw_output),
|
| 169 |
+
severity="Unknown",
|
| 170 |
+
confidence="Low",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
if passport_country and passport_country != "Not specified":
|
| 174 |
+
brief.passport_country = passport_country
|
| 175 |
+
|
| 176 |
+
progress(0.95, desc="Rendering brief...")
|
| 177 |
+
html_output = render_brief_html(brief)
|
| 178 |
+
|
| 179 |
+
trace_lines = [f"=== OSINT Analysis: {country} ==="]
|
| 180 |
+
trace_lines.append(f"Model: {MODEL_ID}")
|
| 181 |
+
trace_lines.append(f"Sources: ACLED + Airspace + State Dept + {sources_str}")
|
| 182 |
+
trace_lines.append(f"Days back: {days_back}")
|
| 183 |
+
trace_lines.append(f"Date: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
|
| 184 |
+
trace_lines.append("\n--- Raw Agent Output ---")
|
| 185 |
+
trace_lines.append(str(raw_output))
|
| 186 |
+
|
| 187 |
+
progress(1.0, desc="Done.")
|
| 188 |
+
return html_output, "\n".join(trace_lines), brief
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# Gradio UI
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
RSS_SOURCE_OPTIONS = [
|
| 196 |
+
# General world news
|
| 197 |
+
("BBC World", "bbc_world"),
|
| 198 |
+
("Al Jazeera", "al_jazeera"),
|
| 199 |
+
("France 24", "france24"),
|
| 200 |
+
("Euronews", "euronews"),
|
| 201 |
+
("NPR World", "npr_world"),
|
| 202 |
+
("Sky News", "sky_news"),
|
| 203 |
+
("UN News", "un_news"),
|
| 204 |
+
("Intl Business Times", "ibt"),
|
| 205 |
+
# Regional: Middle East
|
| 206 |
+
("Middle East Eye", "middle_east_eye"),
|
| 207 |
+
("Al-Monitor", "al_monitor"),
|
| 208 |
+
("Arab News", "arab_news"),
|
| 209 |
+
# Regional: Africa
|
| 210 |
+
("AllAfrica", "allafrica"),
|
| 211 |
+
# Regional: Asia-Pacific
|
| 212 |
+
("Radio Free Asia", "radio_free_asia"),
|
| 213 |
+
("S. China Morning Post", "scmp"),
|
| 214 |
+
# Regional: South Asia
|
| 215 |
+
("Dawn (Pakistan)", "dawn"),
|
| 216 |
+
# Regional: Russia / E. Europe
|
| 217 |
+
("The Moscow Times", "moscow_times"),
|
| 218 |
+
# OSINT / investigative
|
| 219 |
+
("Bellingcat", "bellingcat"),
|
| 220 |
+
("The Intercept", "the_intercept"),
|
| 221 |
+
("OCCRP", "occrp"),
|
| 222 |
+
# Policy / security analysis
|
| 223 |
+
("Crisis Group", "crisis_group"),
|
| 224 |
+
("War on the Rocks", "war_on_rocks"),
|
| 225 |
+
("Just Security", "just_security"),
|
| 226 |
+
("Defense One", "defense_one"),
|
| 227 |
+
("The Cipher Brief", "cipher_brief"),
|
| 228 |
+
("Stimson Center", "stimson"),
|
| 229 |
+
# Human rights
|
| 230 |
+
("Human Rights Watch", "hrw"),
|
| 231 |
+
("Amnesty Intl", "amnesty"),
|
| 232 |
+
]
|
| 233 |
+
|
| 234 |
+
DESTINATION_COUNTRIES = [
|
| 235 |
+
"Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda",
|
| 236 |
+
"Argentina", "Armenia", "Australia", "Austria", "Azerbaijan",
|
| 237 |
+
"Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize",
|
| 238 |
+
"Benin", "Bhutan", "Bolivia", "Bosnia and Herzegovina", "Botswana", "Brazil",
|
| 239 |
+
"Brunei", "Bulgaria", "Burkina Faso", "Burundi",
|
| 240 |
+
"Cabo Verde", "Cambodia", "Cameroon", "Canada", "Central African Republic", "Chad",
|
| 241 |
+
"Chile", "China", "Colombia", "Comoros", "Congo (Republic)", "Congo (DRC)",
|
| 242 |
+
"Costa Rica", "Croatia", "Cuba", "Cyprus", "Czech Republic",
|
| 243 |
+
"Denmark", "Djibouti", "Dominica", "Dominican Republic",
|
| 244 |
+
"Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia",
|
| 245 |
+
"Eswatini", "Ethiopia",
|
| 246 |
+
"Fiji", "Finland", "France",
|
| 247 |
+
"Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Greece", "Grenada",
|
| 248 |
+
"Guatemala", "Guinea", "Guinea-Bissau", "Guyana",
|
| 249 |
+
"Haiti", "Honduras", "Hungary",
|
| 250 |
+
"Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy",
|
| 251 |
+
"Jamaica", "Japan", "Jordan",
|
| 252 |
+
"Kazakhstan", "Kenya", "Kiribati", "Kosovo", "Kuwait", "Kyrgyzstan",
|
| 253 |
+
"Laos", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein",
|
| 254 |
+
"Lithuania", "Luxembourg",
|
| 255 |
+
"Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands",
|
| 256 |
+
"Mauritania", "Mauritius", "Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia",
|
| 257 |
+
"Montenegro", "Morocco", "Mozambique", "Myanmar",
|
| 258 |
+
"Namibia", "Nauru", "Nepal", "Netherlands", "New Zealand", "Nicaragua", "Niger",
|
| 259 |
+
"Nigeria", "North Korea", "North Macedonia", "Norway",
|
| 260 |
+
"Oman",
|
| 261 |
+
"Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea", "Paraguay", "Peru",
|
| 262 |
+
"Philippines", "Poland", "Portugal",
|
| 263 |
+
"Qatar",
|
| 264 |
+
"Romania", "Russia", "Rwanda",
|
| 265 |
+
"Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines",
|
| 266 |
+
"Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal",
|
| 267 |
+
"Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia",
|
| 268 |
+
"Solomon Islands", "Somalia", "South Africa", "South Korea", "South Sudan", "Spain",
|
| 269 |
+
"Sri Lanka", "Sudan", "Suriname", "Sweden", "Switzerland", "Syria",
|
| 270 |
+
"Taiwan", "Tajikistan", "Tanzania", "Thailand", "Timor-Leste", "Togo", "Tonga",
|
| 271 |
+
"Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Tuvalu",
|
| 272 |
+
"Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States",
|
| 273 |
+
"Uruguay", "Uzbekistan",
|
| 274 |
+
"Vanuatu", "Vatican City", "Venezuela", "Vietnam",
|
| 275 |
+
"Yemen",
|
| 276 |
+
"Zambia", "Zimbabwe",
|
| 277 |
+
]
|
| 278 |
+
|
| 279 |
+
PASSPORT_COUNTRIES = [
|
| 280 |
+
"Not specified",
|
| 281 |
+
"Afghanistan", "Albania", "Algeria", "Argentina", "Australia", "Austria",
|
| 282 |
+
"Bangladesh", "Belgium", "Bolivia", "Brazil", "Cambodia", "Canada", "Chile",
|
| 283 |
+
"China", "Colombia", "Croatia", "Czech Republic", "Denmark", "Ecuador", "Egypt",
|
| 284 |
+
"Ethiopia", "Finland", "France", "Germany", "Ghana", "Greece", "Guatemala",
|
| 285 |
+
"Hungary", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy",
|
| 286 |
+
"Japan", "Jordan", "Kazakhstan", "Kenya", "Kuwait", "Malaysia", "Mexico",
|
| 287 |
+
"Morocco", "Nepal", "Netherlands", "New Zealand", "Nigeria", "Norway",
|
| 288 |
+
"Pakistan", "Peru", "Philippines", "Poland", "Portugal", "Qatar",
|
| 289 |
+
"Romania", "Russia", "Saudi Arabia", "Senegal", "Singapore", "South Africa",
|
| 290 |
+
"South Korea", "Spain", "Sri Lanka", "Sudan", "Sweden", "Switzerland",
|
| 291 |
+
"Taiwan", "Thailand", "Turkey", "Ukraine", "United Arab Emirates",
|
| 292 |
+
"United Kingdom", "United States", "Venezuela", "Vietnam", "Zimbabwe",
|
| 293 |
+
]
|
| 294 |
+
|
| 295 |
+
EXAMPLE_QUERIES = [
|
| 296 |
+
["Sudan", ["bbc_world", "al_jazeera", "middle_east_eye", "hrw"], 14],
|
| 297 |
+
["Myanmar", ["bbc_world", "crisis_group", "radio_free_asia", "hrw"], 21],
|
| 298 |
+
["Ukraine", ["bbc_world", "npr_world", "sky_news", "war_on_rocks"], 7],
|
| 299 |
+
["Haiti", ["bbc_world", "al_jazeera", "un_news", "amnesty"], 14],
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
CSS = """
|
| 303 |
+
.gradio-container { max-width: 1100px !important; margin: auto; }
|
| 304 |
+
#analyze-btn { background: #1a1a2e; color: white; }
|
| 305 |
+
#analyze-btn:hover { background: #16213e; }
|
| 306 |
+
footer { display: none !important; }
|
| 307 |
+
"""
|
| 308 |
+
|
| 309 |
+
with gr.Blocks(title="OSINT Threat Analyst", css=CSS, theme=gr.themes.Soft()) as demo:
|
| 310 |
+
|
| 311 |
+
brief_state = gr.State(None)
|
| 312 |
+
|
| 313 |
+
gr.HTML("""
|
| 314 |
+
<div style="text-align:center;padding:20px 0 10px 0">
|
| 315 |
+
<h1 style="font-size:2em;margin:0">π OSINT Threat Analyst</h1>
|
| 316 |
+
<p style="color:#666;margin:6px 0 0 0">
|
| 317 |
+
Agentic multi-source intelligence briefing Β· ACLED + RSS + State Dept + Airspace Β· Powered by HuggingFace
|
| 318 |
+
</p>
|
| 319 |
+
</div>
|
| 320 |
+
<div style="text-align:center;background:#f0f4ff;border:1px solid #c7d4f0;border-radius:8px;padding:12px 24px;max-width:700px;margin:0 auto 16px auto;color:#1a2a5e;font-size:0.95em">
|
| 321 |
+
Select the country you are researching or travelling to, choose your news sources, and click <strong>Run Analysis</strong>.
|
| 322 |
+
</div>
|
| 323 |
+
""")
|
| 324 |
+
|
| 325 |
+
with gr.Row():
|
| 326 |
+
with gr.Column(scale=1):
|
| 327 |
+
gr.Markdown("### Configure Analysis")
|
| 328 |
+
|
| 329 |
+
country_input = gr.Dropdown(
|
| 330 |
+
choices=DESTINATION_COUNTRIES,
|
| 331 |
+
value=None,
|
| 332 |
+
label="Country / Region of Interest",
|
| 333 |
+
info="Type to search and select the country you are researching or travelling to.",
|
| 334 |
+
filterable=True,
|
| 335 |
+
allow_custom_value=True,
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
passport_input = gr.Dropdown(
|
| 339 |
+
choices=PASSPORT_COUNTRIES,
|
| 340 |
+
value="Not specified",
|
| 341 |
+
label="Passport Country",
|
| 342 |
+
info="Select your passport country to include embassy and consulate information in the report.",
|
| 343 |
+
filterable=True,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
with gr.Row():
|
| 347 |
+
select_all_btn = gr.Button("Deselect All", size="sm", scale=1)
|
| 348 |
+
|
| 349 |
+
rss_sources = gr.CheckboxGroup(
|
| 350 |
+
choices=RSS_SOURCE_OPTIONS,
|
| 351 |
+
value=[v for _, v in RSS_SOURCE_OPTIONS],
|
| 352 |
+
label="News Sources",
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
days_back = gr.Slider(
|
| 356 |
+
minimum=7,
|
| 357 |
+
maximum=90,
|
| 358 |
+
value=14,
|
| 359 |
+
step=7,
|
| 360 |
+
label="ACLED lookback window (days)",
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
analyze_btn = gr.Button(
|
| 364 |
+
"π Run Analysis",
|
| 365 |
+
variant="primary",
|
| 366 |
+
elem_id="analyze-btn",
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
with gr.Column(scale=2):
|
| 370 |
+
with gr.Tabs():
|
| 371 |
+
with gr.Tab("π Threat Brief"):
|
| 372 |
+
brief_output = gr.HTML(
|
| 373 |
+
value="<div style='padding:40px;text-align:center;color:#999'>"
|
| 374 |
+
"Configure your query and click Run Analysis.</div>"
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
with gr.Tab("π Raw Agent Trace"):
|
| 378 |
+
trace_output = gr.Textbox(
|
| 379 |
+
label="Agent trace",
|
| 380 |
+
lines=30,
|
| 381 |
+
interactive=False,
|
| 382 |
+
placeholder="Agent reasoning and tool calls will appear here...",
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
with gr.Row():
|
| 386 |
+
export_btn = gr.Button(
|
| 387 |
+
"π Export PDF Report",
|
| 388 |
+
variant="secondary",
|
| 389 |
+
interactive=False,
|
| 390 |
+
scale=1,
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
pdf_file = gr.File(
|
| 394 |
+
label="Download PDF Report",
|
| 395 |
+
visible=False,
|
| 396 |
+
interactive=False,
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
gr.Examples(
|
| 400 |
+
examples=EXAMPLE_QUERIES,
|
| 401 |
+
inputs=[country_input, rss_sources, days_back],
|
| 402 |
+
label="Example Queries",
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
_ALL_SOURCE_KEYS = [v for _, v in RSS_SOURCE_OPTIONS]
|
| 406 |
+
|
| 407 |
+
def toggle_sources(current_values):
|
| 408 |
+
if len(current_values) == len(_ALL_SOURCE_KEYS):
|
| 409 |
+
return gr.update(value=[]), gr.update(value="Select All")
|
| 410 |
+
return gr.update(value=_ALL_SOURCE_KEYS), gr.update(value="Deselect All")
|
| 411 |
+
|
| 412 |
+
select_all_btn.click(
|
| 413 |
+
fn=toggle_sources,
|
| 414 |
+
inputs=[rss_sources],
|
| 415 |
+
outputs=[rss_sources, select_all_btn],
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
analyze_btn.click(
|
| 419 |
+
fn=run_analysis,
|
| 420 |
+
inputs=[country_input, passport_input, rss_sources, days_back],
|
| 421 |
+
outputs=[brief_output, trace_output, brief_state],
|
| 422 |
+
).then(
|
| 423 |
+
fn=lambda b: gr.update(interactive=b is not None),
|
| 424 |
+
inputs=[brief_state],
|
| 425 |
+
outputs=[export_btn],
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
export_btn.click(
|
| 429 |
+
fn=lambda b: generate_pdf(b) if b is not None else None,
|
| 430 |
+
inputs=[brief_state],
|
| 431 |
+
outputs=[pdf_file],
|
| 432 |
+
).then(
|
| 433 |
+
fn=lambda: gr.update(visible=True),
|
| 434 |
+
outputs=[pdf_file],
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
gr.HTML("""
|
| 438 |
+
<div style="text-align:center;padding:16px;color:#999;font-size:0.8em;margin-top:20px">
|
| 439 |
+
β οΈ AI-generated from open sources. Not for operational use without verification.
|
| 440 |
+
Built with <a href="https://github.com/huggingface/smolagents">smolagents</a> Β·
|
| 441 |
+
Data: <a href="https://acleddata.com">ACLED</a> + public RSS feeds + State Dept + EASA/AviationWeather
|
| 442 |
+
</div>
|
| 443 |
+
""")
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
if __name__ == "__main__":
|
| 447 |
+
demo.launch()
|
brief.py
CHANGED
|
@@ -1,552 +1,737 @@
|
|
| 1 |
-
"""
|
| 2 |
-
brief.py β Threat brief output schema and HTML renderer.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
import json
|
| 6 |
-
import re
|
| 7 |
-
from dataclasses import dataclass, field, asdict
|
| 8 |
-
from typing import List, Optional
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
# ---------------------------------------------------------------------------
|
| 12 |
-
# Data schema
|
| 13 |
-
# ---------------------------------------------------------------------------
|
| 14 |
-
|
| 15 |
-
@dataclass
|
| 16 |
-
class NewsItem:
|
| 17 |
-
title: str = ""
|
| 18 |
-
source: str = ""
|
| 19 |
-
published: str = ""
|
| 20 |
-
summary: str = ""
|
| 21 |
-
url: str = ""
|
| 22 |
-
notable: bool = False
|
| 23 |
-
|
| 24 |
-
def to_dict(self) -> dict:
|
| 25 |
-
return asdict(self)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
@dataclass
|
| 29 |
-
class ThreatBrief:
|
| 30 |
-
region: str = ""
|
| 31 |
-
country: str = ""
|
| 32 |
-
assessment_date: str = ""
|
| 33 |
-
severity: str = ""
|
| 34 |
-
confidence: str = ""
|
| 35 |
-
primary_actors: List[str] = field(default_factory=list)
|
| 36 |
-
event_types: List[str] = field(default_factory=list)
|
| 37 |
-
key_locations: List[str] = field(default_factory=list)
|
| 38 |
-
fatalities_reported: Optional[int] = None
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
#
|
| 77 |
-
#
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
"""
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
{
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
brief.py β Threat brief output schema and HTML renderer.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from dataclasses import dataclass, field, asdict
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
# Data schema
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class NewsItem:
|
| 17 |
+
title: str = ""
|
| 18 |
+
source: str = ""
|
| 19 |
+
published: str = ""
|
| 20 |
+
summary: str = ""
|
| 21 |
+
url: str = ""
|
| 22 |
+
notable: bool = False
|
| 23 |
+
|
| 24 |
+
def to_dict(self) -> dict:
|
| 25 |
+
return asdict(self)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class ThreatBrief:
|
| 30 |
+
region: str = ""
|
| 31 |
+
country: str = ""
|
| 32 |
+
assessment_date: str = ""
|
| 33 |
+
severity: str = ""
|
| 34 |
+
confidence: str = ""
|
| 35 |
+
primary_actors: List[str] = field(default_factory=list)
|
| 36 |
+
event_types: List[str] = field(default_factory=list)
|
| 37 |
+
key_locations: List[str] = field(default_factory=list)
|
| 38 |
+
fatalities_reported: Optional[int] = None
|
| 39 |
+
country_summary: str = ""
|
| 40 |
+
narrative_summary: str = ""
|
| 41 |
+
risk_analysis: str = ""
|
| 42 |
+
key_findings: List[str] = field(default_factory=list)
|
| 43 |
+
indicators_of_escalation: List[str] = field(default_factory=list)
|
| 44 |
+
recommended_watch_items: List[str] = field(default_factory=list)
|
| 45 |
+
source_types_used: List[str] = field(default_factory=list)
|
| 46 |
+
recent_events: List[str] = field(default_factory=list)
|
| 47 |
+
notable_news: List[NewsItem] = field(default_factory=list)
|
| 48 |
+
# Travel advisory
|
| 49 |
+
travel_advisory_level: str = ""
|
| 50 |
+
travel_advisory_level_text: str = ""
|
| 51 |
+
travel_advisory_indicators: List[str] = field(default_factory=list)
|
| 52 |
+
travel_advisory_date: str = ""
|
| 53 |
+
travel_advisory_url: str = ""
|
| 54 |
+
# Embassy / consulate
|
| 55 |
+
passport_country: str = ""
|
| 56 |
+
embassy_name: str = ""
|
| 57 |
+
embassy_address: str = ""
|
| 58 |
+
embassy_phone: str = ""
|
| 59 |
+
embassy_emergency_phone: str = ""
|
| 60 |
+
embassy_website: str = ""
|
| 61 |
+
embassy_notes: str = ""
|
| 62 |
+
# Airspace
|
| 63 |
+
airspace_status: str = ""
|
| 64 |
+
airspace_restrictions: List[str] = field(default_factory=list)
|
| 65 |
+
no_fly_zones: List[str] = field(default_factory=list)
|
| 66 |
+
air_defense_activity: List[str] = field(default_factory=list)
|
| 67 |
+
aviation_notes: str = ""
|
| 68 |
+
|
| 69 |
+
def to_dict(self) -> dict:
|
| 70 |
+
d = asdict(self)
|
| 71 |
+
d["notable_news"] = [n.to_dict() for n in self.notable_news]
|
| 72 |
+
return d
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Severity / confidence / advisory colors
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
SEVERITY_COLORS = {
|
| 80 |
+
"Critical": "#C0392B",
|
| 81 |
+
"High": "#E67E22",
|
| 82 |
+
"Medium": "#F39C12",
|
| 83 |
+
"Low": "#27AE60",
|
| 84 |
+
"Minimal": "#7F8C8D",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
CONFIDENCE_COLORS = {
|
| 88 |
+
"High": "#27AE60",
|
| 89 |
+
"Medium": "#F39C12",
|
| 90 |
+
"Low": "#E74C3C",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
ADVISORY_LEVEL_COLORS = {
|
| 94 |
+
"1": "#27AE60",
|
| 95 |
+
"2": "#F39C12",
|
| 96 |
+
"3": "#E67E22",
|
| 97 |
+
"4": "#C0392B",
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
ADVISORY_LEVEL_LABELS = {
|
| 101 |
+
"1": "Exercise Normal Precautions",
|
| 102 |
+
"2": "Exercise Increased Caution",
|
| 103 |
+
"3": "Reconsider Travel",
|
| 104 |
+
"4": "Do Not Travel",
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
AIRSPACE_STATUS_COLORS = {
|
| 108 |
+
"Open": "#27AE60",
|
| 109 |
+
"Restricted": "#E67E22",
|
| 110 |
+
"Partially Restricted": "#F39C12",
|
| 111 |
+
"Closed": "#C0392B",
|
| 112 |
+
"Unknown": "#7F8C8D",
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# Prompt schema
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
BRIEF_PROMPT_SCHEMA = """
|
| 121 |
+
You are an OSINT intelligence analyst. Based on the collected source data above,
|
| 122 |
+
produce a threat brief as a JSON object with EXACTLY these fields:
|
| 123 |
+
|
| 124 |
+
{
|
| 125 |
+
"region": "<geopolitical region, e.g. 'Latin America'>",
|
| 126 |
+
"country": "<primary country of concern>",
|
| 127 |
+
"assessment_date": "<today's date, YYYY-MM-DD>",
|
| 128 |
+
"severity": "<one of: Critical | High | Medium | Low | Minimal>",
|
| 129 |
+
"confidence": "<one of: High | Medium | Low>",
|
| 130 |
+
"primary_actors": ["<actor1>", "<actor2>"],
|
| 131 |
+
"event_types": ["<type1>", "<type2>"],
|
| 132 |
+
"key_locations": ["<location1>", "<location2>"],
|
| 133 |
+
"fatalities_reported": <integer or null>,
|
| 134 |
+
"country_summary": "<3-5 sentence factual background on the country: geography, population, political system, recent history, and why it is geopolitically significant>",
|
| 135 |
+
"narrative_summary": "<2-4 sentence analytical narrative synthesizing all sources>",
|
| 136 |
+
"risk_analysis": "<3-5 sentence focused risk assessment covering: (1) primary threat vectors and actors, (2) likelihood of near-term escalation, (3) risk to civilians, travelers, and humanitarian operations, (4) any compounding factors such as economic collapse, displacement, or regional spillover>",
|
| 137 |
+
"key_findings": ["<finding1>", "<finding2>", "<finding3>"],
|
| 138 |
+
"indicators_of_escalation": ["<indicator1>", "<indicator2>"],
|
| 139 |
+
"recommended_watch_items": ["<watch_item1>", "<watch_item2>"],
|
| 140 |
+
"source_types_used": ["ACLED", "RSS", "State Dept Travel Advisory", "AIRSPACE"],
|
| 141 |
+
"recent_events": [
|
| 142 |
+
"<one-line summary: 'YYYY-MM-DD | LOCATION β description, N fatalities'>",
|
| 143 |
+
"<repeat for each significant event, up to 10>"
|
| 144 |
+
],
|
| 145 |
+
"travel_advisory": {
|
| 146 |
+
"level": "<1|2|3|4 β just the number, or 'Unknown'>",
|
| 147 |
+
"level_text": "<full level string, e.g. 'Level 3: Reconsider Travel'>",
|
| 148 |
+
"indicators": ["<e.g. Crime>", "<e.g. Terrorism>"],
|
| 149 |
+
"date_updated": "<date string from advisory>",
|
| 150 |
+
"url": "<full URL to the advisory page, or empty string>"
|
| 151 |
+
},
|
| 152 |
+
"embassy": {
|
| 153 |
+
"name": "<full official name of the embassy or consulate>",
|
| 154 |
+
"address": "<street address in the destination country>",
|
| 155 |
+
"phone": "<main switchboard phone number including country code>",
|
| 156 |
+
"emergency_phone": "<after-hours emergency line including country code>",
|
| 157 |
+
"website": "<full URL to the embassy website>",
|
| 158 |
+
"notes": "<1-2 sentences: nearest consulate if no embassy, appointment requirements, or emergency procedures>"
|
| 159 |
+
},
|
| 160 |
+
"airspace_status": "<one of: Open | Restricted | Partially Restricted | Closed | Unknown>",
|
| 161 |
+
"airspace_restrictions": ["<restriction or NOTAM description1>", "<restriction2>"],
|
| 162 |
+
"no_fly_zones": ["<zone name or description1>", "<zone2>"],
|
| 163 |
+
"air_defense_activity": ["<activity description1>", "<activity2>"],
|
| 164 |
+
"aviation_notes": "<1-2 sentence summary of overall airspace picture and commercial aviation impact>",
|
| 165 |
+
"notable_news": [
|
| 166 |
+
{
|
| 167 |
+
"title": "<article headline>",
|
| 168 |
+
"source": "<news source name>",
|
| 169 |
+
"published": "<publication date>",
|
| 170 |
+
"summary": "<1-2 sentence analytical summary explaining why this article matters>",
|
| 171 |
+
"url": "<article URL>",
|
| 172 |
+
"notable": true
|
| 173 |
+
}
|
| 174 |
+
]
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
For embassy: provide the PASSPORT_COUNTRY's embassy or nearest consulate in the destination country.
|
| 178 |
+
If no embassy exists there, name the nearest one in a neighbouring country and explain in notes.
|
| 179 |
+
Use your best knowledge of official embassy details; always include the website so the traveller can verify.
|
| 180 |
+
|
| 181 |
+
For recent_events: list each significant ACLED event as a single line in the format
|
| 182 |
+
"DATE | LOCATION β description, N fatalities". Include up to 10 events ordered
|
| 183 |
+
most-recent first. If ACLED returned no data, use an empty array [].
|
| 184 |
+
|
| 185 |
+
For airspace fields: use data from the AIRSPACE tool output (EASA CZIBs, SIGMETs, NOTAMs).
|
| 186 |
+
If no airspace data was retrieved, set airspace_status to "Unknown" and use empty arrays.
|
| 187 |
+
Be concise and factual β cite specific restrictions, zones, or incidents where available.
|
| 188 |
+
|
| 189 |
+
For notable_news: include ALL relevant articles from the RSS results, up to 10.
|
| 190 |
+
Write the summary field in your own analytical words β explain WHY the article
|
| 191 |
+
matters to the threat picture, not just what it says.
|
| 192 |
+
If no RSS articles were retrieved, use an empty array [].
|
| 193 |
+
|
| 194 |
+
Return ONLY the JSON object. No preamble, no markdown fences.
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
# Parser
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
|
| 202 |
+
def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
|
| 203 |
+
cleaned = re.sub(r"```json|```", "", raw_text).strip()
|
| 204 |
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
| 205 |
+
if not match:
|
| 206 |
+
return _fallback_brief(raw_text)
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
data = json.loads(match.group())
|
| 210 |
+
|
| 211 |
+
news_items = []
|
| 212 |
+
for n in data.get("notable_news", []):
|
| 213 |
+
news_items.append(NewsItem(
|
| 214 |
+
title = n.get("title", ""),
|
| 215 |
+
source = n.get("source", ""),
|
| 216 |
+
published= n.get("published", ""),
|
| 217 |
+
summary = n.get("summary", ""),
|
| 218 |
+
url = n.get("url", ""),
|
| 219 |
+
notable = n.get("notable", True),
|
| 220 |
+
))
|
| 221 |
+
|
| 222 |
+
ta = data.get("travel_advisory", {})
|
| 223 |
+
em = data.get("embassy", {})
|
| 224 |
+
|
| 225 |
+
return ThreatBrief(
|
| 226 |
+
region = data.get("region", ""),
|
| 227 |
+
country = data.get("country", ""),
|
| 228 |
+
assessment_date = data.get("assessment_date", ""),
|
| 229 |
+
severity = data.get("severity", "Unknown"),
|
| 230 |
+
confidence = data.get("confidence", "Low"),
|
| 231 |
+
primary_actors = data.get("primary_actors", []),
|
| 232 |
+
event_types = data.get("event_types", []),
|
| 233 |
+
key_locations = data.get("key_locations", []),
|
| 234 |
+
fatalities_reported = data.get("fatalities_reported"),
|
| 235 |
+
country_summary = data.get("country_summary", ""),
|
| 236 |
+
narrative_summary = data.get("narrative_summary", raw_text),
|
| 237 |
+
risk_analysis = data.get("risk_analysis", ""),
|
| 238 |
+
key_findings = data.get("key_findings", []),
|
| 239 |
+
indicators_of_escalation = data.get("indicators_of_escalation", []),
|
| 240 |
+
recommended_watch_items = data.get("recommended_watch_items", []),
|
| 241 |
+
source_types_used = data.get("source_types_used", []),
|
| 242 |
+
recent_events = data.get("recent_events", []),
|
| 243 |
+
notable_news = news_items,
|
| 244 |
+
travel_advisory_level = str(ta.get("level", "")),
|
| 245 |
+
travel_advisory_level_text= ta.get("level_text", ""),
|
| 246 |
+
travel_advisory_indicators= ta.get("indicators", []),
|
| 247 |
+
travel_advisory_date = ta.get("date_updated", ""),
|
| 248 |
+
travel_advisory_url = ta.get("url", ""),
|
| 249 |
+
embassy_name = em.get("name", ""),
|
| 250 |
+
embassy_address = em.get("address", ""),
|
| 251 |
+
embassy_phone = em.get("phone", ""),
|
| 252 |
+
embassy_emergency_phone = em.get("emergency_phone", ""),
|
| 253 |
+
embassy_website = em.get("website", ""),
|
| 254 |
+
embassy_notes = em.get("notes", ""),
|
| 255 |
+
airspace_status = data.get("airspace_status", "Unknown"),
|
| 256 |
+
airspace_restrictions = data.get("airspace_restrictions", []),
|
| 257 |
+
no_fly_zones = data.get("no_fly_zones", []),
|
| 258 |
+
air_defense_activity = data.get("air_defense_activity", []),
|
| 259 |
+
aviation_notes = data.get("aviation_notes", ""),
|
| 260 |
+
)
|
| 261 |
+
except json.JSONDecodeError:
|
| 262 |
+
return _fallback_brief(raw_text)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _fallback_brief(raw_text: str) -> ThreatBrief:
|
| 266 |
+
return ThreatBrief(
|
| 267 |
+
narrative_summary = raw_text,
|
| 268 |
+
severity = "Unknown",
|
| 269 |
+
confidence = "Low",
|
| 270 |
+
key_findings = ["Structured parsing failed β see narrative summary."],
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ---------------------------------------------------------------------------
|
| 275 |
+
# HTML sub-renderers
|
| 276 |
+
# ---------------------------------------------------------------------------
|
| 277 |
+
|
| 278 |
+
def _render_travel_advisory(brief: ThreatBrief) -> str:
|
| 279 |
+
level = brief.travel_advisory_level.strip()
|
| 280 |
+
if not level or level == "Unknown":
|
| 281 |
+
return """
|
| 282 |
+
<div class="section muted-section" style="border-top:none">
|
| 283 |
+
<p class="muted-text">US State Department travel advisory not available for this country.</p>
|
| 284 |
+
</div>"""
|
| 285 |
+
|
| 286 |
+
color = ADVISORY_LEVEL_COLORS.get(level, "#999")
|
| 287 |
+
label = ADVISORY_LEVEL_LABELS.get(level, brief.travel_advisory_level_text or f"Level {level}")
|
| 288 |
+
indicators_html = (
|
| 289 |
+
" Β· ".join(
|
| 290 |
+
f'<span class="risk-tag">{i}</span>' for i in brief.travel_advisory_indicators
|
| 291 |
+
)
|
| 292 |
+
if brief.travel_advisory_indicators else "<em>None listed</em>"
|
| 293 |
+
)
|
| 294 |
+
link_html = (
|
| 295 |
+
f' <a href="{brief.travel_advisory_url}" target="_blank" '
|
| 296 |
+
f'style="font-size:0.82em;color:var(--text-link)">Full advisory β</a>'
|
| 297 |
+
if brief.travel_advisory_url else ""
|
| 298 |
+
)
|
| 299 |
+
date_html = (
|
| 300 |
+
f'<span style="color:var(--text-muted);font-size:0.82em">Updated: {brief.travel_advisory_date}</span>'
|
| 301 |
+
if brief.travel_advisory_date else ""
|
| 302 |
+
)
|
| 303 |
+
return f"""
|
| 304 |
+
<div class="section" style="border-top:none;border-left:4px solid {color}">
|
| 305 |
+
<h3 style="margin-top:0;margin-bottom:10px">
|
| 306 |
+
πΊοΈ US State Dept Travel Advisory
|
| 307 |
+
<span style="background:{color};color:white;padding:2px 12px;border-radius:12px;font-size:0.82em;font-weight:bold;margin-left:8px">
|
| 308 |
+
Level {level}: {label}
|
| 309 |
+
</span>
|
| 310 |
+
{link_html}
|
| 311 |
+
</h3>
|
| 312 |
+
<div style="margin-bottom:6px"><strong>Risk categories:</strong> {indicators_html}</div>
|
| 313 |
+
{date_html}
|
| 314 |
+
</div>"""
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _render_embassy(brief: ThreatBrief) -> str:
|
| 318 |
+
if not brief.passport_country:
|
| 319 |
+
return ""
|
| 320 |
+
|
| 321 |
+
if not brief.embassy_name:
|
| 322 |
+
return f"""
|
| 323 |
+
<div class="section muted-section" style="border-top:none">
|
| 324 |
+
<p class="muted-text">Embassy information for {brief.passport_country} passport holders not available.</p>
|
| 325 |
+
</div>"""
|
| 326 |
+
|
| 327 |
+
website_html = (
|
| 328 |
+
f'<a href="{brief.embassy_website}" target="_blank" '
|
| 329 |
+
f'style="color:var(--text-link);font-size:0.85em">{brief.embassy_website}</a>'
|
| 330 |
+
if brief.embassy_website else "<em>Not available</em>"
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
rows = []
|
| 334 |
+
if brief.embassy_address:
|
| 335 |
+
rows.append(f"<tr><td style='width:160px;color:var(--text-muted);padding:4px 12px 4px 0;vertical-align:top'>Address</td>"
|
| 336 |
+
f"<td style='color:var(--text-body)'>{brief.embassy_address}</td></tr>")
|
| 337 |
+
if brief.embassy_phone:
|
| 338 |
+
rows.append(f"<tr><td style='color:var(--text-muted);padding:4px 12px 4px 0'>Main phone</td>"
|
| 339 |
+
f"<td style='color:var(--text-body)'>{brief.embassy_phone}</td></tr>")
|
| 340 |
+
if brief.embassy_emergency_phone:
|
| 341 |
+
rows.append(f"<tr><td style='color:var(--text-muted);padding:4px 12px 4px 0'>Emergency line</td>"
|
| 342 |
+
f"<td style='color:var(--text-body);font-weight:600'>{brief.embassy_emergency_phone}</td></tr>")
|
| 343 |
+
rows.append(f"<tr><td style='color:var(--text-muted);padding:4px 12px 4px 0'>Website</td>"
|
| 344 |
+
f"<td>{website_html}</td></tr>")
|
| 345 |
+
|
| 346 |
+
notes_html = (
|
| 347 |
+
f'<p style="margin:10px 0 0 0;font-size:0.88em;color:var(--text-muted);font-style:italic">{brief.embassy_notes}</p>'
|
| 348 |
+
if brief.embassy_notes else ""
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
return f"""
|
| 352 |
+
<div class="section" style="border-top:none;border-left:4px solid #1a56db">
|
| 353 |
+
<h3 style="margin-top:0;margin-bottom:10px">
|
| 354 |
+
ποΈ {brief.passport_country} Embassy / Consulate
|
| 355 |
+
<span style="font-size:0.75em;font-weight:normal;color:var(--text-muted);margin-left:8px">in {brief.country}</span>
|
| 356 |
+
</h3>
|
| 357 |
+
<div style="font-weight:600;color:var(--text-primary);margin-bottom:8px">{brief.embassy_name}</div>
|
| 358 |
+
<table style="border-collapse:collapse;font-size:0.9em">{"".join(rows)}</table>
|
| 359 |
+
{notes_html}
|
| 360 |
+
<p style="margin:10px 0 0 0;font-size:0.78em;color:var(--text-muted)">Verify contact details before travel β embassy information can change.</p>
|
| 361 |
+
</div>"""
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ---------------------------------------------------------------------------
|
| 365 |
+
# HTML Renderer
|
| 366 |
+
# ---------------------------------------------------------------------------
|
| 367 |
+
|
| 368 |
+
def render_brief_html(brief: ThreatBrief) -> str:
|
| 369 |
+
sev_color = SEVERITY_COLORS.get(brief.severity, "#999")
|
| 370 |
+
conf_color = CONFIDENCE_COLORS.get(brief.confidence, "#999")
|
| 371 |
+
|
| 372 |
+
def badge(label: str, color: str) -> str:
|
| 373 |
+
return (
|
| 374 |
+
f'<span style="background:{color};color:white;padding:3px 12px;'
|
| 375 |
+
f'border-radius:12px;font-weight:bold;font-size:0.85em">{label}</span>'
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
def bullet_list(items: list) -> str:
|
| 379 |
+
if not items:
|
| 380 |
+
return "<li><em>None identified</em></li>"
|
| 381 |
+
return "".join(f"<li>{i}</li>" for i in items if i and i != "N/A")
|
| 382 |
+
|
| 383 |
+
fatalities_str = (
|
| 384 |
+
str(brief.fatalities_reported)
|
| 385 |
+
if brief.fatalities_reported is not None
|
| 386 |
+
else "Unknown"
|
| 387 |
+
)
|
| 388 |
+
sources_str = ", ".join(brief.source_types_used) if brief.source_types_used else "N/A"
|
| 389 |
+
|
| 390 |
+
# Recent conflict events
|
| 391 |
+
if brief.recent_events:
|
| 392 |
+
event_rows = "".join(
|
| 393 |
+
f"<tr><td style='padding:4px 8px;border-bottom:1px solid var(--border);"
|
| 394 |
+
f"font-family:monospace;font-size:0.82em;color:var(--text-body)'>{e}</td></tr>"
|
| 395 |
+
for e in brief.recent_events
|
| 396 |
+
)
|
| 397 |
+
events_section = f"""
|
| 398 |
+
<div class="section">
|
| 399 |
+
<h3 class="section-title">βοΈ Recent Conflict Events
|
| 400 |
+
<span class="section-count">{len(brief.recent_events)} events Β· ACLED</span>
|
| 401 |
+
</h3>
|
| 402 |
+
<table style="width:100%;border-collapse:collapse">{event_rows}</table>
|
| 403 |
+
</div>"""
|
| 404 |
+
else:
|
| 405 |
+
events_section = """
|
| 406 |
+
<div class="section muted-section">
|
| 407 |
+
<h3 class="section-title">βοΈ Recent Conflict Events</h3>
|
| 408 |
+
<p class="muted-text">No ACLED conflict events retrieved. Check ACLED credentials or try a different date range.</p>
|
| 409 |
+
</div>"""
|
| 410 |
+
|
| 411 |
+
# News cards
|
| 412 |
+
cards = []
|
| 413 |
+
for item in brief.notable_news:
|
| 414 |
+
link_html = (
|
| 415 |
+
f'<a href="{item.url}" target="_blank" class="news-link">Read full article β</a>'
|
| 416 |
+
if item.url else ""
|
| 417 |
+
)
|
| 418 |
+
notable_badge = (
|
| 419 |
+
'<span style="background:#fef3c7;color:#92400e;font-size:0.72em;'
|
| 420 |
+
'padding:1px 6px;border-radius:8px;margin-left:6px;font-weight:600">NOTABLE</span>'
|
| 421 |
+
if item.notable else ""
|
| 422 |
+
)
|
| 423 |
+
cards.append(f"""
|
| 424 |
+
<div class="news-card">
|
| 425 |
+
<div class="news-meta">
|
| 426 |
+
<span class="news-source-badge">{item.source}</span>
|
| 427 |
+
<span class="news-date">{item.published[:25] if item.published else ""}</span>
|
| 428 |
+
{notable_badge}
|
| 429 |
+
</div>
|
| 430 |
+
<div class="news-title">{item.title}</div>
|
| 431 |
+
<div class="news-summary">{item.summary}</div>
|
| 432 |
+
{link_html}
|
| 433 |
+
</div>""")
|
| 434 |
+
|
| 435 |
+
if cards:
|
| 436 |
+
news_section = f"""
|
| 437 |
+
<div class="section">
|
| 438 |
+
<h3 class="section-title">π° Recent News
|
| 439 |
+
<span class="section-count">{len(brief.notable_news)} articles</span>
|
| 440 |
+
</h3>
|
| 441 |
+
{"".join(cards)}
|
| 442 |
+
</div>"""
|
| 443 |
+
else:
|
| 444 |
+
news_section = """
|
| 445 |
+
<div class="section muted-section">
|
| 446 |
+
<h3 class="section-title">π° Recent News</h3>
|
| 447 |
+
<p class="muted-text">No news articles were retrieved. Try adding more RSS sources or broadening the search.</p>
|
| 448 |
+
</div>"""
|
| 449 |
+
|
| 450 |
+
# Airspace section
|
| 451 |
+
as_color = AIRSPACE_STATUS_COLORS.get(brief.airspace_status, "#7F8C8D")
|
| 452 |
+
has_airspace = (
|
| 453 |
+
brief.airspace_restrictions
|
| 454 |
+
or brief.no_fly_zones
|
| 455 |
+
or brief.air_defense_activity
|
| 456 |
+
or brief.aviation_notes
|
| 457 |
+
or (brief.airspace_status and brief.airspace_status not in ("", "Unknown"))
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
if has_airspace:
|
| 461 |
+
airspace_section = f"""
|
| 462 |
+
<div class="section airspace-section">
|
| 463 |
+
<h3 class="section-title">βοΈ Airspace & Aviation Status
|
| 464 |
+
<span style="margin-left:10px">{badge(brief.airspace_status or 'Unknown', as_color)}</span>
|
| 465 |
+
</h3>
|
| 466 |
+
{f'<p class="airspace-notes">{brief.aviation_notes}</p>' if brief.aviation_notes else ""}
|
| 467 |
+
<div class="airspace-grid">
|
| 468 |
+
<div class="airspace-cell">
|
| 469 |
+
<h4>π« No-Fly Zones</h4>
|
| 470 |
+
<ul>{bullet_list(brief.no_fly_zones)}</ul>
|
| 471 |
+
</div>
|
| 472 |
+
<div class="airspace-cell">
|
| 473 |
+
<h4>β οΈ Active Restrictions / NOTAMs</h4>
|
| 474 |
+
<ul>{bullet_list(brief.airspace_restrictions)}</ul>
|
| 475 |
+
</div>
|
| 476 |
+
<div class="airspace-cell airspace-cell-full">
|
| 477 |
+
<h4>π‘οΈ Air Defense Activity</h4>
|
| 478 |
+
<ul>{bullet_list(brief.air_defense_activity)}</ul>
|
| 479 |
+
</div>
|
| 480 |
+
</div>
|
| 481 |
+
</div>"""
|
| 482 |
+
else:
|
| 483 |
+
airspace_section = """
|
| 484 |
+
<div class="section muted-section">
|
| 485 |
+
<p class="muted-text">βοΈ No airspace restriction data retrieved for this country.</p>
|
| 486 |
+
</div>"""
|
| 487 |
+
|
| 488 |
+
html = f"""
|
| 489 |
+
<style>
|
| 490 |
+
.brief-wrap {{
|
| 491 |
+
font-family: system-ui, -apple-system, sans-serif;
|
| 492 |
+
max-width: 920px;
|
| 493 |
+
margin: auto;
|
| 494 |
+
|
| 495 |
+
--bg-primary: #ffffff;
|
| 496 |
+
--bg-secondary: #f4f6f9;
|
| 497 |
+
--bg-accent: #f0f3f8;
|
| 498 |
+
--bg-warn: #fff8e1;
|
| 499 |
+
--bg-news-card: #f8fbff;
|
| 500 |
+
--border: #d0d7e2;
|
| 501 |
+
--text-primary: #1a1a2e;
|
| 502 |
+
--text-body: #2c3444;
|
| 503 |
+
--text-muted: #6b7280;
|
| 504 |
+
--text-link: #2563eb;
|
| 505 |
+
--news-badge-bg: #dbeafe;
|
| 506 |
+
--news-badge-fg: #1d4ed8;
|
| 507 |
+
--header-bg: #1a1a2e;
|
| 508 |
+
--header-text: #ffffff;
|
| 509 |
+
}}
|
| 510 |
+
|
| 511 |
+
@media (prefers-color-scheme: dark) {{
|
| 512 |
+
.brief-wrap {{
|
| 513 |
+
--bg-primary: #1e2130;
|
| 514 |
+
--bg-secondary: #252836;
|
| 515 |
+
--bg-accent: #2a2d3e;
|
| 516 |
+
--bg-warn: #2d2a1a;
|
| 517 |
+
--bg-news-card: #232638;
|
| 518 |
+
--border: #3a3f55;
|
| 519 |
+
--text-primary: #e8eaf6;
|
| 520 |
+
--text-body: #c5c9db;
|
| 521 |
+
--text-muted: #8b91a8;
|
| 522 |
+
--text-link: #7eb3f8;
|
| 523 |
+
--news-badge-bg: #1e3a5f;
|
| 524 |
+
--news-badge-fg: #7eb3f8;
|
| 525 |
+
--header-bg: #111527;
|
| 526 |
+
--header-text: #ffffff;
|
| 527 |
+
}}
|
| 528 |
+
}}
|
| 529 |
+
|
| 530 |
+
.brief-wrap * {{ box-sizing: border-box; }}
|
| 531 |
+
|
| 532 |
+
.brief-header {{
|
| 533 |
+
background: var(--header-bg);
|
| 534 |
+
color: var(--header-text);
|
| 535 |
+
padding: 18px 24px;
|
| 536 |
+
border-radius: 8px 8px 0 0;
|
| 537 |
+
}}
|
| 538 |
+
.brief-header h2 {{ margin: 0 0 4px 0; font-size: 1.3em; }}
|
| 539 |
+
.brief-header p {{ margin: 0; opacity: 0.7; font-size: 0.88em; }}
|
| 540 |
+
|
| 541 |
+
.status-bar {{
|
| 542 |
+
background: var(--bg-accent);
|
| 543 |
+
border: 1px solid var(--border);
|
| 544 |
+
padding: 12px 24px;
|
| 545 |
+
display: flex;
|
| 546 |
+
flex-wrap: wrap;
|
| 547 |
+
gap: 16px;
|
| 548 |
+
align-items: center;
|
| 549 |
+
color: var(--text-body);
|
| 550 |
+
font-size: 0.9em;
|
| 551 |
+
}}
|
| 552 |
+
.status-bar strong {{ color: var(--text-primary); }}
|
| 553 |
+
|
| 554 |
+
.section {{
|
| 555 |
+
background: var(--bg-primary);
|
| 556 |
+
border: 1px solid var(--border);
|
| 557 |
+
border-top: none;
|
| 558 |
+
padding: 18px 24px;
|
| 559 |
+
color: var(--text-body);
|
| 560 |
+
}}
|
| 561 |
+
.section h3, .section h4 {{ color: var(--text-primary); margin-top: 0; }}
|
| 562 |
+
.section p {{ line-height: 1.8; margin: 0; }}
|
| 563 |
+
.section ul {{ padding-left: 20px; line-height: 1.9; margin: 0; }}
|
| 564 |
+
|
| 565 |
+
.section-title {{ margin-bottom: 14px; font-size: 1.05em; }}
|
| 566 |
+
.section-count {{
|
| 567 |
+
font-size: 0.68em;
|
| 568 |
+
font-weight: normal;
|
| 569 |
+
color: var(--text-muted);
|
| 570 |
+
margin-left: 8px;
|
| 571 |
+
}}
|
| 572 |
+
|
| 573 |
+
.grid-2 {{
|
| 574 |
+
display: grid;
|
| 575 |
+
grid-template-columns: 1fr 1fr;
|
| 576 |
+
border: 1px solid var(--border);
|
| 577 |
+
border-top: none;
|
| 578 |
+
}}
|
| 579 |
+
.grid-cell {{
|
| 580 |
+
background: var(--bg-primary);
|
| 581 |
+
padding: 16px 20px;
|
| 582 |
+
color: var(--text-body);
|
| 583 |
+
}}
|
| 584 |
+
.grid-cell h4 {{ color: var(--text-primary); margin-top: 0; }}
|
| 585 |
+
.grid-cell ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
|
| 586 |
+
.grid-cell-border {{ border-right: 1px solid var(--border); }}
|
| 587 |
+
|
| 588 |
+
.muted-section {{ background: var(--bg-secondary); }}
|
| 589 |
+
.muted-text {{ color: var(--text-muted); font-style: italic; margin: 0; }}
|
| 590 |
+
|
| 591 |
+
.news-card {{
|
| 592 |
+
background: var(--bg-news-card);
|
| 593 |
+
border: 1px solid var(--border);
|
| 594 |
+
border-radius: 6px;
|
| 595 |
+
padding: 12px 16px;
|
| 596 |
+
margin-bottom: 10px;
|
| 597 |
+
}}
|
| 598 |
+
.news-meta {{ margin-bottom: 6px; }}
|
| 599 |
+
.news-source-badge {{
|
| 600 |
+
background: var(--news-badge-bg);
|
| 601 |
+
color: var(--news-badge-fg);
|
| 602 |
+
padding: 2px 8px;
|
| 603 |
+
border-radius: 8px;
|
| 604 |
+
font-size: 0.78em;
|
| 605 |
+
font-weight: 600;
|
| 606 |
+
}}
|
| 607 |
+
.news-date {{ color: var(--text-muted); font-size: 0.8em; margin-left: 8px; }}
|
| 608 |
+
.news-title {{ font-weight: 600; color: var(--text-primary); margin-bottom: 5px; line-height: 1.4; }}
|
| 609 |
+
.news-summary {{ color: var(--text-body); font-size: 0.9em; line-height: 1.6; margin-bottom: 6px; }}
|
| 610 |
+
.news-link {{ color: var(--text-link); font-size: 0.82em; text-decoration: none; }}
|
| 611 |
+
.news-link:hover {{ text-decoration: underline; }}
|
| 612 |
+
|
| 613 |
+
.airspace-section {{
|
| 614 |
+
background: var(--bg-primary);
|
| 615 |
+
border: 1px solid var(--border);
|
| 616 |
+
border-top: none;
|
| 617 |
+
padding: 18px 24px;
|
| 618 |
+
color: var(--text-body);
|
| 619 |
+
}}
|
| 620 |
+
.airspace-notes {{ line-height: 1.7; margin: 0 0 14px 0; color: var(--text-body); }}
|
| 621 |
+
.airspace-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 0; }}
|
| 622 |
+
.airspace-cell {{ padding: 12px 16px 12px 0; border-right: 1px solid var(--border); }}
|
| 623 |
+
.airspace-cell:last-child {{ border-right: none; padding-right: 0; }}
|
| 624 |
+
.airspace-cell-full {{
|
| 625 |
+
grid-column: 1 / -1;
|
| 626 |
+
border-right: none;
|
| 627 |
+
border-top: 1px solid var(--border);
|
| 628 |
+
padding-top: 12px;
|
| 629 |
+
margin-top: 4px;
|
| 630 |
+
}}
|
| 631 |
+
.airspace-cell h4 {{ color: var(--text-primary); margin-top: 0; margin-bottom: 8px; font-size: 0.95em; }}
|
| 632 |
+
.airspace-cell ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
|
| 633 |
+
|
| 634 |
+
.warn-section {{
|
| 635 |
+
background: var(--bg-warn);
|
| 636 |
+
border: 1px solid var(--border);
|
| 637 |
+
border-top: none;
|
| 638 |
+
padding: 16px 24px;
|
| 639 |
+
color: var(--text-body);
|
| 640 |
+
}}
|
| 641 |
+
.warn-section h4 {{ color: var(--text-primary); margin-top: 0; }}
|
| 642 |
+
.warn-section ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
|
| 643 |
+
|
| 644 |
+
.risk-tag {{
|
| 645 |
+
background: var(--bg-accent);
|
| 646 |
+
border: 1px solid var(--border);
|
| 647 |
+
color: var(--text-body);
|
| 648 |
+
padding: 1px 8px;
|
| 649 |
+
border-radius: 6px;
|
| 650 |
+
font-size: 0.82em;
|
| 651 |
+
}}
|
| 652 |
+
|
| 653 |
+
.brief-footer {{
|
| 654 |
+
background: var(--bg-secondary);
|
| 655 |
+
border: 1px solid var(--border);
|
| 656 |
+
border-top: none;
|
| 657 |
+
border-radius: 0 0 8px 8px;
|
| 658 |
+
padding: 10px 24px;
|
| 659 |
+
font-size: 0.78em;
|
| 660 |
+
color: var(--text-muted);
|
| 661 |
+
}}
|
| 662 |
+
</style>
|
| 663 |
+
|
| 664 |
+
<div class="brief-wrap">
|
| 665 |
+
|
| 666 |
+
<div class="brief-header">
|
| 667 |
+
<h2>π‘οΈ OSINT Threat Brief</h2>
|
| 668 |
+
<p>{brief.country or 'Unknown'} | {brief.region} | {brief.assessment_date}</p>
|
| 669 |
+
</div>
|
| 670 |
+
|
| 671 |
+
<div class="status-bar">
|
| 672 |
+
<span><strong>Severity:</strong> {badge(brief.severity, sev_color)}</span>
|
| 673 |
+
<span><strong>Confidence:</strong> {badge(brief.confidence, conf_color)}</span>
|
| 674 |
+
<span><strong>Sources:</strong> {sources_str}</span>
|
| 675 |
+
<span><strong>Fatalities:</strong> {fatalities_str}</span>
|
| 676 |
+
</div>
|
| 677 |
+
|
| 678 |
+
<div class="section">
|
| 679 |
+
<h3>π Country Background</h3>
|
| 680 |
+
<p>{brief.country_summary or '<em>Not available</em>'}</p>
|
| 681 |
+
</div>
|
| 682 |
+
|
| 683 |
+
{_render_travel_advisory(brief)}
|
| 684 |
+
|
| 685 |
+
{_render_embassy(brief)}
|
| 686 |
+
|
| 687 |
+
<div class="section">
|
| 688 |
+
<h3>π Analytical Summary</h3>
|
| 689 |
+
<p>{brief.narrative_summary or '<em>Not available</em>'}</p>
|
| 690 |
+
</div>
|
| 691 |
+
|
| 692 |
+
<div class="section" style="border-left:4px solid #C0392B">
|
| 693 |
+
<h3 style="margin-top:0;color:#C0392B">π΄ Risk Assessment</h3>
|
| 694 |
+
<p>{brief.risk_analysis or '<em>Risk assessment not available.</em>'}</p>
|
| 695 |
+
</div>
|
| 696 |
+
|
| 697 |
+
<div class="grid-2">
|
| 698 |
+
<div class="grid-cell grid-cell-border">
|
| 699 |
+
<h4>π Key Findings</h4>
|
| 700 |
+
<ul>{bullet_list(brief.key_findings)}</ul>
|
| 701 |
+
</div>
|
| 702 |
+
<div class="grid-cell">
|
| 703 |
+
<h4>β οΈ Escalation Indicators</h4>
|
| 704 |
+
<ul>{bullet_list(brief.indicators_of_escalation)}</ul>
|
| 705 |
+
</div>
|
| 706 |
+
</div>
|
| 707 |
+
|
| 708 |
+
<div class="grid-2">
|
| 709 |
+
<div class="grid-cell grid-cell-border" style="border-top:1px solid var(--border)">
|
| 710 |
+
<h4>π Primary Actors</h4>
|
| 711 |
+
<ul>{bullet_list(brief.primary_actors)}</ul>
|
| 712 |
+
</div>
|
| 713 |
+
<div class="grid-cell" style="border-top:1px solid var(--border)">
|
| 714 |
+
<h4>π Key Locations</h4>
|
| 715 |
+
<ul>{bullet_list(brief.key_locations)}</ul>
|
| 716 |
+
</div>
|
| 717 |
+
</div>
|
| 718 |
+
|
| 719 |
+
{airspace_section}
|
| 720 |
+
|
| 721 |
+
{events_section}
|
| 722 |
+
|
| 723 |
+
{news_section}
|
| 724 |
+
|
| 725 |
+
<div class="warn-section">
|
| 726 |
+
<h4>π‘ Recommended Watch Items</h4>
|
| 727 |
+
<ul>{bullet_list(brief.recommended_watch_items)}</ul>
|
| 728 |
+
</div>
|
| 729 |
+
|
| 730 |
+
<div class="brief-footer">
|
| 731 |
+
Event types: {', '.join(brief.event_types) or 'N/A'} |
|
| 732 |
+
<em>AI-generated from open sources. Verify before operational use.</em>
|
| 733 |
+
</div>
|
| 734 |
+
|
| 735 |
+
</div>
|
| 736 |
+
"""
|
| 737 |
+
return html
|
export.py
CHANGED
|
@@ -37,6 +37,14 @@ _CONFIDENCE_COLORS = {
|
|
| 37 |
"Low": (231, 76, 60),
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# ---------------------------------------------------------------------------
|
| 42 |
# PDF class
|
|
@@ -70,14 +78,12 @@ class _BriefPDF(FPDF):
|
|
| 70 |
# ---------------------------------------------------------------------------
|
| 71 |
|
| 72 |
def _safe(text: str) -> str:
|
| 73 |
-
"""Replace characters that latin-1 cannot encode so fpdf2 won't error."""
|
| 74 |
if not isinstance(text, str):
|
| 75 |
text = str(text)
|
| 76 |
return text.encode("latin-1", errors="replace").decode("latin-1")
|
| 77 |
|
| 78 |
|
| 79 |
def _reset_x(pdf: FPDF) -> None:
|
| 80 |
-
"""Always call this before multi_cell to prevent cursor-drift errors."""
|
| 81 |
pdf.set_x(pdf.l_margin)
|
| 82 |
|
| 83 |
|
|
@@ -124,10 +130,7 @@ def _color_badge(pdf: FPDF, label: str, rgb: tuple, x: float, y: float, w: float
|
|
| 124 |
# ---------------------------------------------------------------------------
|
| 125 |
|
| 126 |
def generate_pdf(brief: ThreatBrief) -> str:
|
| 127 |
-
"""
|
| 128 |
-
Build a PDF from a ThreatBrief and return the path to the temp file.
|
| 129 |
-
Caller is responsible for cleanup.
|
| 130 |
-
"""
|
| 131 |
pdf = _BriefPDF()
|
| 132 |
pdf.set_auto_page_break(auto=True, margin=18)
|
| 133 |
pdf.add_page()
|
|
@@ -149,10 +152,10 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 149 |
pdf.ln(4)
|
| 150 |
|
| 151 |
# ---- Severity / Confidence / Fatalities row ----
|
| 152 |
-
badge_y
|
| 153 |
sev_rgb = _SEVERITY_COLORS.get(brief.severity, (150, 150, 150))
|
| 154 |
conf_rgb = _CONFIDENCE_COLORS.get(brief.confidence, (150, 150, 150))
|
| 155 |
-
_color_badge(pdf, f"Severity: {brief.severity}",
|
| 156 |
_color_badge(pdf, f"Confidence: {brief.confidence}", conf_rgb, pdf.l_margin + 62, badge_y, w=58)
|
| 157 |
|
| 158 |
fat = str(brief.fatalities_reported) if brief.fatalities_reported is not None else "Unknown"
|
|
@@ -173,12 +176,11 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 173 |
if map_path:
|
| 174 |
_section_heading(pdf, f"Location Map β {brief.country}")
|
| 175 |
try:
|
| 176 |
-
# Render at 55% of page width, centred
|
| 177 |
map_w = pdf.epw * 0.55
|
| 178 |
map_x = pdf.l_margin + (pdf.epw - map_w) / 2
|
| 179 |
pdf.image(map_path, x=map_x, w=map_w)
|
| 180 |
except Exception:
|
| 181 |
-
pass
|
| 182 |
finally:
|
| 183 |
try:
|
| 184 |
os.unlink(map_path)
|
|
@@ -202,7 +204,7 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 202 |
_body_text(pdf, brief.country_summary)
|
| 203 |
pdf.ln(3)
|
| 204 |
|
| 205 |
-
# ----
|
| 206 |
_section_heading(pdf, "U.S. State Department Travel Advisory")
|
| 207 |
|
| 208 |
level = (brief.travel_advisory_level or "").strip()
|
|
@@ -234,7 +236,6 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 234 |
pdf.set_text_color(37, 99, 235)
|
| 235 |
advisory_link = brief.travel_advisory_url or "https://travel.state.gov/content/travel/en/traveladvisories/traveladvisories.html"
|
| 236 |
pdf.cell(pdf.epw, 5, _safe(f"Source: {advisory_link}"), ln=True)
|
| 237 |
-
|
| 238 |
_reset_x(pdf)
|
| 239 |
pdf.set_text_color(44, 52, 68)
|
| 240 |
pdf.ln(4)
|
|
@@ -251,13 +252,12 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 251 |
pdf.cell(pdf.epw, 5, _safe(brief.embassy_name), ln=True)
|
| 252 |
pdf.set_font("Helvetica", "", 9)
|
| 253 |
|
| 254 |
-
|
| 255 |
("Address", brief.embassy_address),
|
| 256 |
("Main phone", brief.embassy_phone),
|
| 257 |
("Emergency line", brief.embassy_emergency_phone),
|
| 258 |
("Website", brief.embassy_website),
|
| 259 |
-
]
|
| 260 |
-
for label, value in fields:
|
| 261 |
if value:
|
| 262 |
_reset_x(pdf)
|
| 263 |
pdf.set_font("Helvetica", "B", 8)
|
|
@@ -274,7 +274,7 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 274 |
_reset_x(pdf)
|
| 275 |
pdf.set_font("Helvetica", "I", 7)
|
| 276 |
pdf.set_text_color(150, 150, 160)
|
| 277 |
-
pdf.cell(pdf.epw, 4,
|
| 278 |
pdf.set_text_color(44, 52, 68)
|
| 279 |
pdf.ln(3)
|
| 280 |
|
|
@@ -287,13 +287,6 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 287 |
|
| 288 |
# ---- Risk Assessment ----
|
| 289 |
_reset_x(pdf)
|
| 290 |
-
pdf.set_fill_color(250, 242, 242)
|
| 291 |
-
pdf.set_draw_color(192, 57, 43)
|
| 292 |
-
pdf.set_text_color(192, 57, 43)
|
| 293 |
-
pdf.set_font("Helvetica", "B", 10)
|
| 294 |
-
# Left red rule + shaded heading
|
| 295 |
-
pdf.set_x(pdf.l_margin)
|
| 296 |
-
pdf.cell(3, 7, "", fill=False, ln=False) # 3pt red left bar
|
| 297 |
pdf.set_fill_color(192, 57, 43)
|
| 298 |
pdf.cell(3, 7, "", fill=True, ln=False)
|
| 299 |
pdf.set_fill_color(250, 242, 242)
|
|
@@ -302,7 +295,7 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 302 |
pdf.set_font("Helvetica", "", 9)
|
| 303 |
pdf.set_text_color(44, 52, 68)
|
| 304 |
_body_text(pdf, brief.risk_analysis or "Not available.")
|
| 305 |
-
pdf.set_draw_color(210, 215, 226)
|
| 306 |
pdf.ln(3)
|
| 307 |
|
| 308 |
# ---- Key Findings ----
|
|
@@ -313,7 +306,46 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 313 |
_section_heading(pdf, "Escalation Indicators")
|
| 314 |
_bullet_list(pdf, brief.indicators_of_escalation)
|
| 315 |
|
| 316 |
-
# ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
_section_heading(pdf, f"Recent Conflict Events ({len(brief.recent_events)} events Β· ACLED)")
|
| 318 |
if brief.recent_events:
|
| 319 |
pdf.set_font("Courier", "", 8)
|
|
@@ -332,14 +364,13 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 332 |
pdf.ln(2)
|
| 333 |
|
| 334 |
# ---- Primary Actors / Key Locations ----
|
| 335 |
-
# Rendered as two plain lists rather than side-by-side to avoid cursor-drift bugs
|
| 336 |
_section_heading(pdf, "Primary Actors")
|
| 337 |
_bullet_list(pdf, brief.primary_actors)
|
| 338 |
|
| 339 |
_section_heading(pdf, "Key Locations")
|
| 340 |
_bullet_list(pdf, brief.key_locations)
|
| 341 |
|
| 342 |
-
# ---- Recent News
|
| 343 |
_section_heading(pdf, f"Recent News ({len(brief.notable_news)} articles)")
|
| 344 |
if brief.notable_news:
|
| 345 |
for article in brief.notable_news:
|
|
@@ -364,7 +395,6 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 364 |
_reset_x(pdf)
|
| 365 |
pdf.set_font("Helvetica", "I", 8)
|
| 366 |
pdf.set_text_color(37, 99, 235)
|
| 367 |
-
# Truncate very long URLs so cell doesn't overflow
|
| 368 |
url_display = article.url[:90] + "..." if len(article.url) > 90 else article.url
|
| 369 |
pdf.cell(pdf.epw, 4, _safe(url_display), ln=True)
|
| 370 |
|
|
@@ -375,7 +405,7 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 375 |
_reset_x(pdf)
|
| 376 |
pdf.set_font("Helvetica", "I", 9)
|
| 377 |
pdf.set_text_color(140, 140, 150)
|
| 378 |
-
pdf.cell(pdf.epw, 5, "No news articles retrieved.
|
| 379 |
_reset_x(pdf)
|
| 380 |
pdf.ln(2)
|
| 381 |
|
|
@@ -390,7 +420,6 @@ def generate_pdf(brief: ThreatBrief) -> str:
|
|
| 390 |
sources = ", ".join(brief.source_types_used) if brief.source_types_used else "N/A"
|
| 391 |
pdf.multi_cell(pdf.epw, 4, _safe(f"Sources used: {sources}"))
|
| 392 |
|
| 393 |
-
# ---- Write to temp file ----
|
| 394 |
path = tempfile.mktemp(suffix=".pdf")
|
| 395 |
pdf.output(path)
|
| 396 |
return path
|
|
|
|
| 37 |
"Low": (231, 76, 60),
|
| 38 |
}
|
| 39 |
|
| 40 |
+
_AIRSPACE_COLORS = {
|
| 41 |
+
"Open": (39, 174, 96),
|
| 42 |
+
"Restricted": (230, 126, 34),
|
| 43 |
+
"Partially Restricted": (243, 156, 18),
|
| 44 |
+
"Closed": (192, 57, 43),
|
| 45 |
+
"Unknown": (127, 140, 141),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
|
| 49 |
# ---------------------------------------------------------------------------
|
| 50 |
# PDF class
|
|
|
|
| 78 |
# ---------------------------------------------------------------------------
|
| 79 |
|
| 80 |
def _safe(text: str) -> str:
|
|
|
|
| 81 |
if not isinstance(text, str):
|
| 82 |
text = str(text)
|
| 83 |
return text.encode("latin-1", errors="replace").decode("latin-1")
|
| 84 |
|
| 85 |
|
| 86 |
def _reset_x(pdf: FPDF) -> None:
|
|
|
|
| 87 |
pdf.set_x(pdf.l_margin)
|
| 88 |
|
| 89 |
|
|
|
|
| 130 |
# ---------------------------------------------------------------------------
|
| 131 |
|
| 132 |
def generate_pdf(brief: ThreatBrief) -> str:
|
| 133 |
+
"""Build a PDF from a ThreatBrief and return the path to the temp file."""
|
|
|
|
|
|
|
|
|
|
| 134 |
pdf = _BriefPDF()
|
| 135 |
pdf.set_auto_page_break(auto=True, margin=18)
|
| 136 |
pdf.add_page()
|
|
|
|
| 152 |
pdf.ln(4)
|
| 153 |
|
| 154 |
# ---- Severity / Confidence / Fatalities row ----
|
| 155 |
+
badge_y = pdf.get_y()
|
| 156 |
sev_rgb = _SEVERITY_COLORS.get(brief.severity, (150, 150, 150))
|
| 157 |
conf_rgb = _CONFIDENCE_COLORS.get(brief.confidence, (150, 150, 150))
|
| 158 |
+
_color_badge(pdf, f"Severity: {brief.severity}", sev_rgb, pdf.l_margin, badge_y, w=58)
|
| 159 |
_color_badge(pdf, f"Confidence: {brief.confidence}", conf_rgb, pdf.l_margin + 62, badge_y, w=58)
|
| 160 |
|
| 161 |
fat = str(brief.fatalities_reported) if brief.fatalities_reported is not None else "Unknown"
|
|
|
|
| 176 |
if map_path:
|
| 177 |
_section_heading(pdf, f"Location Map β {brief.country}")
|
| 178 |
try:
|
|
|
|
| 179 |
map_w = pdf.epw * 0.55
|
| 180 |
map_x = pdf.l_margin + (pdf.epw - map_w) / 2
|
| 181 |
pdf.image(map_path, x=map_x, w=map_w)
|
| 182 |
except Exception:
|
| 183 |
+
pass
|
| 184 |
finally:
|
| 185 |
try:
|
| 186 |
os.unlink(map_path)
|
|
|
|
| 204 |
_body_text(pdf, brief.country_summary)
|
| 205 |
pdf.ln(3)
|
| 206 |
|
| 207 |
+
# ---- Travel Advisory ----
|
| 208 |
_section_heading(pdf, "U.S. State Department Travel Advisory")
|
| 209 |
|
| 210 |
level = (brief.travel_advisory_level or "").strip()
|
|
|
|
| 236 |
pdf.set_text_color(37, 99, 235)
|
| 237 |
advisory_link = brief.travel_advisory_url or "https://travel.state.gov/content/travel/en/traveladvisories/traveladvisories.html"
|
| 238 |
pdf.cell(pdf.epw, 5, _safe(f"Source: {advisory_link}"), ln=True)
|
|
|
|
| 239 |
_reset_x(pdf)
|
| 240 |
pdf.set_text_color(44, 52, 68)
|
| 241 |
pdf.ln(4)
|
|
|
|
| 252 |
pdf.cell(pdf.epw, 5, _safe(brief.embassy_name), ln=True)
|
| 253 |
pdf.set_font("Helvetica", "", 9)
|
| 254 |
|
| 255 |
+
for label, value in [
|
| 256 |
("Address", brief.embassy_address),
|
| 257 |
("Main phone", brief.embassy_phone),
|
| 258 |
("Emergency line", brief.embassy_emergency_phone),
|
| 259 |
("Website", brief.embassy_website),
|
| 260 |
+
]:
|
|
|
|
| 261 |
if value:
|
| 262 |
_reset_x(pdf)
|
| 263 |
pdf.set_font("Helvetica", "B", 8)
|
|
|
|
| 274 |
_reset_x(pdf)
|
| 275 |
pdf.set_font("Helvetica", "I", 7)
|
| 276 |
pdf.set_text_color(150, 150, 160)
|
| 277 |
+
pdf.cell(pdf.epw, 4, "Verify contact details before travel - embassy information can change.", ln=True)
|
| 278 |
pdf.set_text_color(44, 52, 68)
|
| 279 |
pdf.ln(3)
|
| 280 |
|
|
|
|
| 287 |
|
| 288 |
# ---- Risk Assessment ----
|
| 289 |
_reset_x(pdf)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
pdf.set_fill_color(192, 57, 43)
|
| 291 |
pdf.cell(3, 7, "", fill=True, ln=False)
|
| 292 |
pdf.set_fill_color(250, 242, 242)
|
|
|
|
| 295 |
pdf.set_font("Helvetica", "", 9)
|
| 296 |
pdf.set_text_color(44, 52, 68)
|
| 297 |
_body_text(pdf, brief.risk_analysis or "Not available.")
|
| 298 |
+
pdf.set_draw_color(210, 215, 226)
|
| 299 |
pdf.ln(3)
|
| 300 |
|
| 301 |
# ---- Key Findings ----
|
|
|
|
| 306 |
_section_heading(pdf, "Escalation Indicators")
|
| 307 |
_bullet_list(pdf, brief.indicators_of_escalation)
|
| 308 |
|
| 309 |
+
# ---- Airspace Status ----
|
| 310 |
+
as_status = brief.airspace_status or "Unknown"
|
| 311 |
+
as_rgb = _AIRSPACE_COLORS.get(as_status, (127, 140, 141))
|
| 312 |
+
_section_heading(pdf, f"Airspace & Aviation Status")
|
| 313 |
+
|
| 314 |
+
_reset_x(pdf)
|
| 315 |
+
pdf.set_fill_color(*as_rgb)
|
| 316 |
+
pdf.set_text_color(255, 255, 255)
|
| 317 |
+
pdf.set_font("Helvetica", "B", 9)
|
| 318 |
+
pdf.cell(60, 6, _safe(f" Status: {as_status}"), fill=True, ln=True)
|
| 319 |
+
|
| 320 |
+
pdf.set_text_color(44, 52, 68)
|
| 321 |
+
pdf.set_font("Helvetica", "", 9)
|
| 322 |
+
|
| 323 |
+
if brief.aviation_notes:
|
| 324 |
+
_reset_x(pdf)
|
| 325 |
+
pdf.multi_cell(pdf.epw, 5, _safe(brief.aviation_notes))
|
| 326 |
+
pdf.ln(1)
|
| 327 |
+
|
| 328 |
+
if brief.no_fly_zones:
|
| 329 |
+
_reset_x(pdf)
|
| 330 |
+
pdf.set_font("Helvetica", "B", 8)
|
| 331 |
+
pdf.cell(pdf.epw, 4, "No-Fly Zones:", ln=True)
|
| 332 |
+
_bullet_list(pdf, brief.no_fly_zones)
|
| 333 |
+
|
| 334 |
+
if brief.airspace_restrictions:
|
| 335 |
+
_reset_x(pdf)
|
| 336 |
+
pdf.set_font("Helvetica", "B", 8)
|
| 337 |
+
pdf.cell(pdf.epw, 4, "Active Restrictions / NOTAMs:", ln=True)
|
| 338 |
+
_bullet_list(pdf, brief.airspace_restrictions)
|
| 339 |
+
|
| 340 |
+
if brief.air_defense_activity:
|
| 341 |
+
_reset_x(pdf)
|
| 342 |
+
pdf.set_font("Helvetica", "B", 8)
|
| 343 |
+
pdf.cell(pdf.epw, 4, "Air Defense Activity:", ln=True)
|
| 344 |
+
_bullet_list(pdf, brief.air_defense_activity)
|
| 345 |
+
|
| 346 |
+
pdf.ln(2)
|
| 347 |
+
|
| 348 |
+
# ---- Recent Conflict Events ----
|
| 349 |
_section_heading(pdf, f"Recent Conflict Events ({len(brief.recent_events)} events Β· ACLED)")
|
| 350 |
if brief.recent_events:
|
| 351 |
pdf.set_font("Courier", "", 8)
|
|
|
|
| 364 |
pdf.ln(2)
|
| 365 |
|
| 366 |
# ---- Primary Actors / Key Locations ----
|
|
|
|
| 367 |
_section_heading(pdf, "Primary Actors")
|
| 368 |
_bullet_list(pdf, brief.primary_actors)
|
| 369 |
|
| 370 |
_section_heading(pdf, "Key Locations")
|
| 371 |
_bullet_list(pdf, brief.key_locations)
|
| 372 |
|
| 373 |
+
# ---- Recent News ----
|
| 374 |
_section_heading(pdf, f"Recent News ({len(brief.notable_news)} articles)")
|
| 375 |
if brief.notable_news:
|
| 376 |
for article in brief.notable_news:
|
|
|
|
| 395 |
_reset_x(pdf)
|
| 396 |
pdf.set_font("Helvetica", "I", 8)
|
| 397 |
pdf.set_text_color(37, 99, 235)
|
|
|
|
| 398 |
url_display = article.url[:90] + "..." if len(article.url) > 90 else article.url
|
| 399 |
pdf.cell(pdf.epw, 4, _safe(url_display), ln=True)
|
| 400 |
|
|
|
|
| 405 |
_reset_x(pdf)
|
| 406 |
pdf.set_font("Helvetica", "I", 9)
|
| 407 |
pdf.set_text_color(140, 140, 150)
|
| 408 |
+
pdf.cell(pdf.epw, 5, "No news articles retrieved.", ln=True)
|
| 409 |
_reset_x(pdf)
|
| 410 |
pdf.ln(2)
|
| 411 |
|
|
|
|
| 420 |
sources = ", ".join(brief.source_types_used) if brief.source_types_used else "N/A"
|
| 421 |
pdf.multi_cell(pdf.epw, 4, _safe(f"Sources used: {sources}"))
|
| 422 |
|
|
|
|
| 423 |
path = tempfile.mktemp(suffix=".pdf")
|
| 424 |
pdf.output(path)
|
| 425 |
return path
|
map_utils.py
CHANGED
|
@@ -1,11 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
map_utils.py β
|
| 3 |
-
with the country border highlighted in red.
|
| 4 |
-
|
| 5 |
-
No API key required. Uses:
|
| 6 |
-
- OpenStreetMap Nominatim for geocoding + border polygon
|
| 7 |
-
- OpenStreetMap tile server for the base map
|
| 8 |
-
- Pillow to composite tiles and draw the border
|
| 9 |
"""
|
| 10 |
|
| 11 |
import io
|
|
@@ -31,20 +25,19 @@ _HEADERS = {
|
|
| 31 |
|
| 32 |
def _deg2tile(lat: float, lon: float, zoom: int) -> tuple:
|
| 33 |
lat_r = math.radians(lat)
|
| 34 |
-
n
|
| 35 |
-
x
|
| 36 |
-
y
|
| 37 |
return x, y
|
| 38 |
|
| 39 |
|
| 40 |
def _geo_to_px(lat: float, lon: float, x_min_tile: int, y_min_tile: int, zoom: int) -> tuple:
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
py = int((ty - y_min_tile) * TILE_SIZE)
|
| 48 |
return px, py
|
| 49 |
|
| 50 |
|
|
@@ -65,29 +58,20 @@ def _zoom_for_bbox(north: float, south: float, east: float, west: float) -> int:
|
|
| 65 |
|
| 66 |
def _draw_border(canvas: Image.Image, geojson: dict,
|
| 67 |
x_min_tile: int, y_min_tile: int, zoom: int) -> None:
|
| 68 |
-
|
| 69 |
-
Draw the country border polygon(s) on *canvas* in red.
|
| 70 |
-
Handles both Polygon and MultiPolygon geometry types.
|
| 71 |
-
"""
|
| 72 |
-
draw = ImageDraw.Draw(canvas)
|
| 73 |
geom_type = geojson.get("type", "")
|
| 74 |
coords = geojson.get("coordinates", [])
|
| 75 |
|
| 76 |
def draw_ring(ring):
|
| 77 |
pixels = [_geo_to_px(lat, lon, x_min_tile, y_min_tile, zoom) for lon, lat in ring]
|
| 78 |
if len(pixels) >= 2:
|
| 79 |
-
# Thick red outline
|
| 80 |
draw.line(pixels + [pixels[0]], fill=(220, 30, 30), width=3)
|
| 81 |
-
# Thin white inner stroke for contrast
|
| 82 |
draw.line(pixels + [pixels[0]], fill=(255, 255, 255), width=1)
|
| 83 |
|
| 84 |
if geom_type == "Polygon":
|
| 85 |
-
# coords[0] is the outer ring; the rest are holes β skip holes
|
| 86 |
if coords:
|
| 87 |
draw_ring(coords[0])
|
| 88 |
-
|
| 89 |
elif geom_type == "MultiPolygon":
|
| 90 |
-
# Each entry in coords is a polygon (list of rings); draw outer rings only
|
| 91 |
for polygon in coords:
|
| 92 |
if polygon:
|
| 93 |
draw_ring(polygon[0])
|
|
@@ -105,7 +89,6 @@ def generate_country_map(country: str) -> Optional[str]:
|
|
| 105 |
if not country or not country.strip():
|
| 106 |
return None
|
| 107 |
|
| 108 |
-
# 1. Geocode + fetch border polygon from Nominatim in one request
|
| 109 |
try:
|
| 110 |
resp = requests.get(
|
| 111 |
NOMINATIM_URL,
|
|
@@ -122,7 +105,6 @@ def generate_country_map(country: str) -> Optional[str]:
|
|
| 122 |
if not results:
|
| 123 |
return None
|
| 124 |
|
| 125 |
-
# Prefer boundary / administrative results
|
| 126 |
entry = None
|
| 127 |
for r in results:
|
| 128 |
if r.get("type") in ("administrative", "country") or r.get("class") == "boundary":
|
|
@@ -131,17 +113,15 @@ def generate_country_map(country: str) -> Optional[str]:
|
|
| 131 |
if entry is None:
|
| 132 |
entry = results[0]
|
| 133 |
|
| 134 |
-
bb = entry.get("boundingbox")
|
| 135 |
if not bb or len(bb) != 4:
|
| 136 |
return None
|
| 137 |
south, north, west, east = (float(v) for v in bb)
|
| 138 |
-
|
| 139 |
-
geojson = entry.get("geojson") # polygon geometry, may be None
|
| 140 |
|
| 141 |
except Exception:
|
| 142 |
return None
|
| 143 |
|
| 144 |
-
# 2. Zoom + tile range
|
| 145 |
zoom = _zoom_for_bbox(north, south, east, west)
|
| 146 |
n_total = 2 ** zoom
|
| 147 |
|
|
@@ -153,7 +133,6 @@ def generate_country_map(country: str) -> Optional[str]:
|
|
| 153 |
y_min = min(y_sw, y_ne) - 1
|
| 154 |
y_max = max(y_sw, y_ne) + 1
|
| 155 |
|
| 156 |
-
# Clamp to at most 5Γ4 tiles to keep the image compact
|
| 157 |
if x_max - x_min > 4:
|
| 158 |
cx = (x_min + x_max) // 2
|
| 159 |
x_min, x_max = cx - 2, cx + 2
|
|
@@ -161,10 +140,8 @@ def generate_country_map(country: str) -> Optional[str]:
|
|
| 161 |
cy = (y_min + y_max) // 2
|
| 162 |
y_min, y_max = cy - 1, cy + 2
|
| 163 |
|
| 164 |
-
cols
|
| 165 |
-
rows
|
| 166 |
-
|
| 167 |
-
# 3. Download and composite tiles
|
| 168 |
canvas = Image.new("RGB", (cols * TILE_SIZE, rows * TILE_SIZE), (200, 205, 210))
|
| 169 |
|
| 170 |
for row_idx, ty in enumerate(range(y_min, y_max + 1)):
|
|
@@ -180,14 +157,12 @@ def generate_country_map(country: str) -> Optional[str]:
|
|
| 180 |
except Exception:
|
| 181 |
pass
|
| 182 |
|
| 183 |
-
# 4. Draw country border
|
| 184 |
if geojson:
|
| 185 |
try:
|
| 186 |
_draw_border(canvas, geojson, x_min, y_min, zoom)
|
| 187 |
except Exception:
|
| 188 |
-
pass
|
| 189 |
|
| 190 |
-
# 5. OSM attribution (required by tile usage policy)
|
| 191 |
try:
|
| 192 |
draw = ImageDraw.Draw(canvas)
|
| 193 |
attr_text = "Β© OpenStreetMap contributors"
|
|
@@ -197,7 +172,6 @@ def generate_country_map(country: str) -> Optional[str]:
|
|
| 197 |
except Exception:
|
| 198 |
pass
|
| 199 |
|
| 200 |
-
# 6. Save
|
| 201 |
try:
|
| 202 |
tmp = tempfile.mktemp(suffix=".png")
|
| 203 |
canvas.save(tmp, format="PNG")
|
|
|
|
| 1 |
"""
|
| 2 |
+
map_utils.py β Generate an OSM tile map with country border for PDF export.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import io
|
|
|
|
| 25 |
|
| 26 |
def _deg2tile(lat: float, lon: float, zoom: int) -> tuple:
|
| 27 |
lat_r = math.radians(lat)
|
| 28 |
+
n = 2 ** zoom
|
| 29 |
+
x = int((lon + 180.0) / 360.0 * n)
|
| 30 |
+
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
|
| 31 |
return x, y
|
| 32 |
|
| 33 |
|
| 34 |
def _geo_to_px(lat: float, lon: float, x_min_tile: int, y_min_tile: int, zoom: int) -> tuple:
|
| 35 |
+
n = 2 ** zoom
|
| 36 |
+
tx = (lon + 180.0) / 360.0 * n
|
| 37 |
+
lat_r = math.radians(lat)
|
| 38 |
+
ty = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
|
| 39 |
+
px = int((tx - x_min_tile) * TILE_SIZE)
|
| 40 |
+
py = int((ty - y_min_tile) * TILE_SIZE)
|
|
|
|
| 41 |
return px, py
|
| 42 |
|
| 43 |
|
|
|
|
| 58 |
|
| 59 |
def _draw_border(canvas: Image.Image, geojson: dict,
|
| 60 |
x_min_tile: int, y_min_tile: int, zoom: int) -> None:
|
| 61 |
+
draw = ImageDraw.Draw(canvas)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
geom_type = geojson.get("type", "")
|
| 63 |
coords = geojson.get("coordinates", [])
|
| 64 |
|
| 65 |
def draw_ring(ring):
|
| 66 |
pixels = [_geo_to_px(lat, lon, x_min_tile, y_min_tile, zoom) for lon, lat in ring]
|
| 67 |
if len(pixels) >= 2:
|
|
|
|
| 68 |
draw.line(pixels + [pixels[0]], fill=(220, 30, 30), width=3)
|
|
|
|
| 69 |
draw.line(pixels + [pixels[0]], fill=(255, 255, 255), width=1)
|
| 70 |
|
| 71 |
if geom_type == "Polygon":
|
|
|
|
| 72 |
if coords:
|
| 73 |
draw_ring(coords[0])
|
|
|
|
| 74 |
elif geom_type == "MultiPolygon":
|
|
|
|
| 75 |
for polygon in coords:
|
| 76 |
if polygon:
|
| 77 |
draw_ring(polygon[0])
|
|
|
|
| 89 |
if not country or not country.strip():
|
| 90 |
return None
|
| 91 |
|
|
|
|
| 92 |
try:
|
| 93 |
resp = requests.get(
|
| 94 |
NOMINATIM_URL,
|
|
|
|
| 105 |
if not results:
|
| 106 |
return None
|
| 107 |
|
|
|
|
| 108 |
entry = None
|
| 109 |
for r in results:
|
| 110 |
if r.get("type") in ("administrative", "country") or r.get("class") == "boundary":
|
|
|
|
| 113 |
if entry is None:
|
| 114 |
entry = results[0]
|
| 115 |
|
| 116 |
+
bb = entry.get("boundingbox")
|
| 117 |
if not bb or len(bb) != 4:
|
| 118 |
return None
|
| 119 |
south, north, west, east = (float(v) for v in bb)
|
| 120 |
+
geojson = entry.get("geojson")
|
|
|
|
| 121 |
|
| 122 |
except Exception:
|
| 123 |
return None
|
| 124 |
|
|
|
|
| 125 |
zoom = _zoom_for_bbox(north, south, east, west)
|
| 126 |
n_total = 2 ** zoom
|
| 127 |
|
|
|
|
| 133 |
y_min = min(y_sw, y_ne) - 1
|
| 134 |
y_max = max(y_sw, y_ne) + 1
|
| 135 |
|
|
|
|
| 136 |
if x_max - x_min > 4:
|
| 137 |
cx = (x_min + x_max) // 2
|
| 138 |
x_min, x_max = cx - 2, cx + 2
|
|
|
|
| 140 |
cy = (y_min + y_max) // 2
|
| 141 |
y_min, y_max = cy - 1, cy + 2
|
| 142 |
|
| 143 |
+
cols = x_max - x_min + 1
|
| 144 |
+
rows = y_max - y_min + 1
|
|
|
|
|
|
|
| 145 |
canvas = Image.new("RGB", (cols * TILE_SIZE, rows * TILE_SIZE), (200, 205, 210))
|
| 146 |
|
| 147 |
for row_idx, ty in enumerate(range(y_min, y_max + 1)):
|
|
|
|
| 157 |
except Exception:
|
| 158 |
pass
|
| 159 |
|
|
|
|
| 160 |
if geojson:
|
| 161 |
try:
|
| 162 |
_draw_border(canvas, geojson, x_min, y_min, zoom)
|
| 163 |
except Exception:
|
| 164 |
+
pass
|
| 165 |
|
|
|
|
| 166 |
try:
|
| 167 |
draw = ImageDraw.Draw(canvas)
|
| 168 |
attr_text = "Β© OpenStreetMap contributors"
|
|
|
|
| 172 |
except Exception:
|
| 173 |
pass
|
| 174 |
|
|
|
|
| 175 |
try:
|
| 176 |
tmp = tempfile.mktemp(suffix=".png")
|
| 177 |
canvas.save(tmp, format="PNG")
|
tools.py
CHANGED
|
@@ -1,618 +1,747 @@
|
|
| 1 |
-
"""
|
| 2 |
-
tools.py β OSINT data source tools for the agentic analyst loop.
|
| 3 |
-
|
| 4 |
-
Required Space Secrets:
|
| 5 |
-
ACLED_USERNAME β your myACLED email
|
| 6 |
-
ACLED_PASSWORD β your myACLED password
|
| 7 |
-
|
| 8 |
-
Optional Space Secrets (enhance airspace data):
|
| 9 |
-
AVWX_TOKEN β free API token from https://avwx.rest (enables per-airport NOTAM lookups)
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
import os
|
| 13 |
-
import re
|
| 14 |
-
import time
|
| 15 |
-
import threading
|
| 16 |
-
import feedparser
|
| 17 |
-
import requests
|
| 18 |
-
from datetime import datetime, timedelta
|
| 19 |
-
from smolagents import tool
|
| 20 |
-
|
| 21 |
-
# ---------------------------------------------------------------------------
|
| 22 |
-
# ACLED OAuth token cache
|
| 23 |
-
# ---------------------------------------------------------------------------
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _get_acled_token() -> str:
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
"
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
"
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
"
|
| 168 |
-
"
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
"
|
| 178 |
-
"
|
| 179 |
-
"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
"
|
| 187 |
-
"
|
| 188 |
-
"
|
| 189 |
-
|
| 190 |
-
"
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
"""
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
f"
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
"
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
)
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
)
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
f"
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tools.py β OSINT data source tools for the agentic analyst loop.
|
| 3 |
+
|
| 4 |
+
Required Space Secrets:
|
| 5 |
+
ACLED_USERNAME β your myACLED account email (from https://developer.acleddata.com)
|
| 6 |
+
ACLED_PASSWORD β your myACLED account password
|
| 7 |
+
|
| 8 |
+
Optional Space Secrets (enhance airspace data):
|
| 9 |
+
AVWX_TOKEN β free API token from https://avwx.rest (enables per-airport NOTAM lookups)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
import time
|
| 15 |
+
import threading
|
| 16 |
+
import feedparser
|
| 17 |
+
import requests
|
| 18 |
+
from datetime import datetime, timedelta
|
| 19 |
+
from smolagents import tool
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# ACLED OAuth 2.0 token cache
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
ACLED_TOKEN_URL = "https://acleddata.com/oauth/token"
|
| 26 |
+
ACLED_BASE = "https://acleddata.com/api/acled/read"
|
| 27 |
+
|
| 28 |
+
_token_cache: dict = {
|
| 29 |
+
"access_token": None,
|
| 30 |
+
"expires_at": 0.0,
|
| 31 |
+
"lock": threading.Lock(),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _get_acled_token() -> str:
|
| 36 |
+
"""Return a valid Bearer token, refreshing via OAuth password grant if needed."""
|
| 37 |
+
with _token_cache["lock"]:
|
| 38 |
+
now = time.time()
|
| 39 |
+
if _token_cache["access_token"] and now < _token_cache["expires_at"]:
|
| 40 |
+
return _token_cache["access_token"]
|
| 41 |
+
|
| 42 |
+
username = os.environ.get("ACLED_USERNAME", "").strip()
|
| 43 |
+
password = os.environ.get("ACLED_PASSWORD", "").strip()
|
| 44 |
+
|
| 45 |
+
if not username or not password:
|
| 46 |
+
raise EnvironmentError(
|
| 47 |
+
"ACLED credentials missing. "
|
| 48 |
+
"Add ACLED_USERNAME and ACLED_PASSWORD as Space secrets "
|
| 49 |
+
"(Settings β Variables and Secrets). "
|
| 50 |
+
"Register free at https://developer.acleddata.com"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
resp = requests.post(
|
| 55 |
+
ACLED_TOKEN_URL,
|
| 56 |
+
data={
|
| 57 |
+
"grant_type": "password",
|
| 58 |
+
"client_id": "acled",
|
| 59 |
+
"username": username,
|
| 60 |
+
"password": password,
|
| 61 |
+
},
|
| 62 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 63 |
+
timeout=20,
|
| 64 |
+
)
|
| 65 |
+
except requests.RequestException as e:
|
| 66 |
+
raise EnvironmentError(f"ACLED token request failed (network): {e}") from e
|
| 67 |
+
|
| 68 |
+
if resp.status_code != 200:
|
| 69 |
+
raise EnvironmentError(
|
| 70 |
+
f"ACLED token request failed (HTTP {resp.status_code}): {resp.text[:300]}"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
token_data = resp.json()
|
| 75 |
+
except ValueError:
|
| 76 |
+
raise EnvironmentError(f"ACLED token response not JSON: {resp.text[:300]}")
|
| 77 |
+
|
| 78 |
+
access_token = token_data.get("access_token")
|
| 79 |
+
if not access_token:
|
| 80 |
+
raise EnvironmentError(
|
| 81 |
+
f"ACLED token response missing 'access_token'. Got: {token_data}"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
expires_in = token_data.get("expires_in", 86400)
|
| 85 |
+
_token_cache["access_token"] = access_token
|
| 86 |
+
_token_cache["expires_at"] = now + int(expires_in) - 300
|
| 87 |
+
return access_token
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _strip_html(text: str) -> str:
|
| 91 |
+
"""Remove HTML tags and clean up whitespace."""
|
| 92 |
+
clean = re.sub(r"<[^>]+>", " ", text)
|
| 93 |
+
clean = re.sub(r"\s+", " ", clean)
|
| 94 |
+
return clean.strip()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# ACLED Tool
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
@tool
|
| 102 |
+
def fetch_acled_events(country: str, days_back: int = 14, limit: int = 15) -> str:
|
| 103 |
+
"""
|
| 104 |
+
Fetches recent armed conflict events from ACLED for a given country.
|
| 105 |
+
Returns dates, locations, actor names, event types, and fatality counts.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
country: Country name to query (e.g. 'Sudan', 'Ukraine', 'Mexico').
|
| 109 |
+
days_back: How many days back to search (default 14).
|
| 110 |
+
limit: Maximum number of events to return (default 15, max 50).
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
token = _get_acled_token()
|
| 114 |
+
except EnvironmentError as e:
|
| 115 |
+
return f"[ACLED] Auth error: {e}"
|
| 116 |
+
|
| 117 |
+
since = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
| 118 |
+
|
| 119 |
+
params = {
|
| 120 |
+
"country": country,
|
| 121 |
+
"event_date": since,
|
| 122 |
+
"event_date_where": ">=",
|
| 123 |
+
"limit": min(limit, 50),
|
| 124 |
+
"fields": "event_date|event_type|sub_event_type|actor1|actor2|location|admin1|fatalities|notes",
|
| 125 |
+
"_format": "json",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
resp = requests.get(ACLED_BASE, params=params, headers=headers, timeout=20)
|
| 132 |
+
except requests.RequestException as e:
|
| 133 |
+
return f"[ACLED] Request failed (network): {e}"
|
| 134 |
+
|
| 135 |
+
if resp.status_code != 200:
|
| 136 |
+
return f"[ACLED] HTTP {resp.status_code}: {resp.text[:300]}"
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
data = resp.json()
|
| 140 |
+
except ValueError:
|
| 141 |
+
return f"[ACLED] Could not parse response as JSON. Raw: {resp.text[:300]}"
|
| 142 |
+
|
| 143 |
+
api_status = data.get("status")
|
| 144 |
+
if api_status != 200:
|
| 145 |
+
msg = data.get("error") or data.get("message") or str(data)[:300]
|
| 146 |
+
return f"[ACLED] API status {api_status}: {msg}"
|
| 147 |
+
|
| 148 |
+
events = data.get("data", [])
|
| 149 |
+
if not events:
|
| 150 |
+
return f"[ACLED] No events found for '{country}' in the last {days_back} days."
|
| 151 |
+
|
| 152 |
+
lines = [f"[ACLED] {len(events)} events in {country} (last {days_back} days):\n"]
|
| 153 |
+
total_fatalities = 0
|
| 154 |
+
|
| 155 |
+
for ev in events:
|
| 156 |
+
fatalities = int(ev.get("fatalities") or 0)
|
| 157 |
+
total_fatalities += fatalities
|
| 158 |
+
actor2_str = f" vs {ev['actor2']}" if ev.get("actor2") else ""
|
| 159 |
+
lines.append(
|
| 160 |
+
f"* {ev.get('event_date', '?')} | {ev.get('event_type', '?')} / {ev.get('sub_event_type', '')} | "
|
| 161 |
+
f"{ev.get('location', '?')}, {ev.get('admin1', '?')} | "
|
| 162 |
+
f"{ev.get('actor1', '?')}{actor2_str} | "
|
| 163 |
+
f"Fatalities: {fatalities} | "
|
| 164 |
+
f"Notes: {str(ev.get('notes', ''))[:80]}"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
lines.append(f"\nTotal reported fatalities: {total_fatalities}")
|
| 168 |
+
return "\n".join(lines)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# RSS Tool
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
RSS_FEED_REGISTRY = {
|
| 176 |
+
# --- General world news ---
|
| 177 |
+
"bbc_world": "https://feeds.bbci.co.uk/news/world/rss.xml",
|
| 178 |
+
"al_jazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
| 179 |
+
"france24": "https://www.france24.com/en/rss",
|
| 180 |
+
"euronews": "https://feeds.feedburner.com/euronews/en/news/",
|
| 181 |
+
"npr_world": "https://feeds.npr.org/1004/rss.xml",
|
| 182 |
+
"sky_news": "https://feeds.skynews.com/feeds/rss/world.xml",
|
| 183 |
+
"un_news": "https://news.un.org/feed/subscribe/en/news/all/rss.xml",
|
| 184 |
+
"ibt": "https://www.ibtimes.com/rss",
|
| 185 |
+
# --- Regional: Middle East ---
|
| 186 |
+
"middle_east_eye": "https://www.middleeasteye.net/rss",
|
| 187 |
+
"al_monitor": "https://www.almonitor.com/rss",
|
| 188 |
+
"arab_news": "https://www.arabnews.com/rss.xml",
|
| 189 |
+
# --- Regional: Africa ---
|
| 190 |
+
"allafrica": "https://allafrica.com/tools/headlines/rdf/latest/headlines.rdf",
|
| 191 |
+
# --- Regional: Asia-Pacific ---
|
| 192 |
+
"radio_free_asia": "https://www.rfa.org/english/rss2.xml",
|
| 193 |
+
"scmp": "https://www.scmp.com/rss/91/feed",
|
| 194 |
+
# --- Regional: South Asia ---
|
| 195 |
+
"dawn": "https://www.dawn.com/feeds/home",
|
| 196 |
+
# --- Regional: Russia / Eastern Europe ---
|
| 197 |
+
"moscow_times": "https://www.themoscowtimes.com/rss/news",
|
| 198 |
+
# --- OSINT / investigative ---
|
| 199 |
+
"bellingcat": "https://www.bellingcat.com/feed/",
|
| 200 |
+
"the_intercept": "https://theintercept.com/feed/?rss",
|
| 201 |
+
"occrp": "https://www.occrp.org/en/component/rssfeed/index.xml",
|
| 202 |
+
# --- Policy / security analysis ---
|
| 203 |
+
"crisis_group": "https://www.crisisgroup.org/rss.xml",
|
| 204 |
+
"war_on_rocks": "https://warontherocks.com/feed/",
|
| 205 |
+
"just_security": "https://www.justsecurity.org/feed/",
|
| 206 |
+
"defense_one": "https://www.defenseone.com/rss/all/",
|
| 207 |
+
"cipher_brief": "https://www.thecipherbrief.com/feed",
|
| 208 |
+
"stimson": "https://www.stimson.org/feed/",
|
| 209 |
+
# --- Human rights ---
|
| 210 |
+
"hrw": "https://www.hrw.org/rss.xml",
|
| 211 |
+
"amnesty": "https://www.amnesty.org/en/feed/",
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
AVIATION_RSS_SOURCES = {
|
| 215 |
+
"aviation_herald": "https://avherald.com/h?subscribe=rss",
|
| 216 |
+
"the_aviationist": "https://theaviationist.com/feed/",
|
| 217 |
+
"flight_global": "https://www.flightglobal.com/rss/",
|
| 218 |
+
"aviation_week": "https://aviationweek.com/rss/all",
|
| 219 |
+
"alert5": "https://alert5.com/feed/",
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
SCAN_LIMIT = 20 # Max entries scanned per feed β keeps LLM context manageable
|
| 223 |
+
|
| 224 |
+
NOTABLE_SIGNALS = [
|
| 225 |
+
"killed", "dead", "deaths", "fatalities", "massacre", "attack", "attacked",
|
| 226 |
+
"explosion", "bomb", "bombing", "shooting", "gunfire", "clash", "clashes",
|
| 227 |
+
"offensive", "invasion", "coup", "crisis", "emergency", "arrest", "arrested",
|
| 228 |
+
"protest", "riot", "siege", "hostage", "kidnap", "cartel", "militia",
|
| 229 |
+
"sanctions", "airstrike", "drone", "ceasefire", "peace", "agreement",
|
| 230 |
+
"earthquake", "flood", "disaster", "outbreak", "epidemic",
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
AIRSPACE_SIGNALS = [
|
| 234 |
+
"notam", "no-fly", "no fly", "airspace", "flight ban", "flight restriction",
|
| 235 |
+
"fir", "air defense", "air defence", "missile", "anti-aircraft",
|
| 236 |
+
"flight advisory", "aviation", "closed airspace", "restricted airspace",
|
| 237 |
+
"drone", "uav", "uas", "aircraft", "airline", "airport", "runway",
|
| 238 |
+
"air traffic", "eurocontrol", "icao", "overflight", "air corridor",
|
| 239 |
+
]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _is_notable(title: str, summary: str) -> bool:
|
| 243 |
+
text = (title + " " + summary).lower()
|
| 244 |
+
return any(signal in text for signal in NOTABLE_SIGNALS)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@tool
|
| 248 |
+
def fetch_rss_headlines(
|
| 249 |
+
topic: str,
|
| 250 |
+
sources: str = "bbc_world,al_jazeera,france24",
|
| 251 |
+
max_articles: int = 10,
|
| 252 |
+
) -> str:
|
| 253 |
+
"""
|
| 254 |
+
Fetches recent RSS news headlines related to a topic or region.
|
| 255 |
+
Returns structured article records including title, source, date, summary,
|
| 256 |
+
URL, and a 'notable' flag for high-signal security/conflict articles.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
topic: Keyword or region to filter headlines (e.g. 'Mexico', 'Sudan').
|
| 260 |
+
Single keywords work best.
|
| 261 |
+
sources: Comma-separated source keys. Available: bbc_world, al_jazeera,
|
| 262 |
+
france24, euronews, npr_world, sky_news, un_news, ibt,
|
| 263 |
+
middle_east_eye, al_monitor, arab_news, allafrica,
|
| 264 |
+
radio_free_asia, scmp, dawn, moscow_times,
|
| 265 |
+
bellingcat, the_intercept, occrp,
|
| 266 |
+
crisis_group, war_on_rocks, just_security, defense_one,
|
| 267 |
+
cipher_brief, stimson, hrw, amnesty.
|
| 268 |
+
max_articles: Maximum total articles to return across all sources (default 10).
|
| 269 |
+
"""
|
| 270 |
+
_ALIASES = {
|
| 271 |
+
"myanmar": ["myanmar", "burma"],
|
| 272 |
+
"burma": ["myanmar", "burma"],
|
| 273 |
+
"ivory coast": ["ivory coast", "cΓ΄te d'ivoire"],
|
| 274 |
+
"drc": ["drc", "congo", "democratic republic"],
|
| 275 |
+
"car": ["central african republic", "car"],
|
| 276 |
+
"uae": ["uae", "united arab emirates"],
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
source_keys = [s.strip() for s in sources.split(",") if s.strip()]
|
| 280 |
+
base_keywords = [w.lower() for w in topic.lower().split() if len(w) > 2]
|
| 281 |
+
topic_lower = topic.lower().strip()
|
| 282 |
+
extra = _ALIASES.get(topic_lower, [])
|
| 283 |
+
keywords = list(dict.fromkeys(base_keywords + extra))
|
| 284 |
+
articles = []
|
| 285 |
+
feed_errors = []
|
| 286 |
+
|
| 287 |
+
for key in source_keys:
|
| 288 |
+
if len(articles) >= max_articles:
|
| 289 |
+
break
|
| 290 |
+
|
| 291 |
+
url = RSS_FEED_REGISTRY.get(key)
|
| 292 |
+
if not url:
|
| 293 |
+
feed_errors.append(f"Unknown source key: '{key}'")
|
| 294 |
+
continue
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
feed = feedparser.parse(url)
|
| 298 |
+
if feed.bozo and not feed.entries:
|
| 299 |
+
feed_errors.append(f"[{key}] Feed parse error: {feed.bozo_exception}")
|
| 300 |
+
continue
|
| 301 |
+
except Exception as e:
|
| 302 |
+
feed_errors.append(f"[{key}] Exception: {e}")
|
| 303 |
+
continue
|
| 304 |
+
|
| 305 |
+
source_name = feed.feed.get("title", key)
|
| 306 |
+
|
| 307 |
+
for entry in feed.entries[:SCAN_LIMIT]:
|
| 308 |
+
if len(articles) >= max_articles:
|
| 309 |
+
break
|
| 310 |
+
|
| 311 |
+
title = entry.get("title", "").strip()
|
| 312 |
+
raw_summary = entry.get("summary", entry.get("description", ""))
|
| 313 |
+
summary = _strip_html(raw_summary)[:150]
|
| 314 |
+
published = entry.get("published", entry.get("updated", ""))
|
| 315 |
+
link = entry.get("link", "")
|
| 316 |
+
|
| 317 |
+
searchable = (title + " " + summary).lower()
|
| 318 |
+
if not any(kw in searchable for kw in keywords):
|
| 319 |
+
continue
|
| 320 |
+
|
| 321 |
+
articles.append({
|
| 322 |
+
"source_key": key,
|
| 323 |
+
"source_name": source_name,
|
| 324 |
+
"published": published,
|
| 325 |
+
"title": title,
|
| 326 |
+
"summary": summary,
|
| 327 |
+
"url": link,
|
| 328 |
+
"notable": _is_notable(title, summary),
|
| 329 |
+
})
|
| 330 |
+
|
| 331 |
+
time.sleep(0.3)
|
| 332 |
+
|
| 333 |
+
if not articles:
|
| 334 |
+
err_detail = "; ".join(feed_errors) if feed_errors else "no entries matched"
|
| 335 |
+
return (
|
| 336 |
+
f"[RSS] No articles matched '{topic}'. {err_detail}\n"
|
| 337 |
+
"Tip: Try a shorter single-word keyword (e.g. 'Mexico' not 'Mexico violence')."
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
lines = [f"[RSS] {len(articles)} articles found for '{topic}':\n"]
|
| 341 |
+
notable_count = sum(1 for a in articles if a["notable"])
|
| 342 |
+
lines.append(f"Notable (high-signal) articles: {notable_count} of {len(articles)}\n")
|
| 343 |
+
|
| 344 |
+
for i, a in enumerate(articles, 1):
|
| 345 |
+
flag = " *** NOTABLE ***" if a["notable"] else ""
|
| 346 |
+
lines.append(
|
| 347 |
+
f"[{i}] {a['source_name']} | {a['published']}{flag}\n"
|
| 348 |
+
f" Title: {a['title']}\n"
|
| 349 |
+
f" Summary: {a['summary']}\n"
|
| 350 |
+
f" URL: {a['url']}\n"
|
| 351 |
+
f" Notable: {a['notable']}"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
if feed_errors:
|
| 355 |
+
lines.append("\n--- Feed warnings ---")
|
| 356 |
+
lines.extend(feed_errors)
|
| 357 |
+
|
| 358 |
+
return "\n\n".join(lines)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# ---------------------------------------------------------------------------
|
| 362 |
+
# US State Department Travel Advisory Tool
|
| 363 |
+
# ---------------------------------------------------------------------------
|
| 364 |
+
|
| 365 |
+
_ADVISORY_API = "https://cadataapi.state.gov/api/TravelAdvisories"
|
| 366 |
+
|
| 367 |
+
_RISK_KEYWORDS = {
|
| 368 |
+
"crime": "Crime",
|
| 369 |
+
"terrorism": "Terrorism",
|
| 370 |
+
"civil unrest": "Civil Unrest",
|
| 371 |
+
"health": "Health",
|
| 372 |
+
"natural disaster": "Natural Disaster",
|
| 373 |
+
"kidnapping": "Kidnapping",
|
| 374 |
+
"wrongful detention": "Wrongful Detention",
|
| 375 |
+
"piracy": "Piracy",
|
| 376 |
+
"maritime": "Maritime",
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@tool
|
| 381 |
+
def fetch_travel_advisory(country: str) -> str:
|
| 382 |
+
"""
|
| 383 |
+
Fetches the current US State Department travel advisory for a country.
|
| 384 |
+
Returns the advisory level (1β4), risk categories, publication date,
|
| 385 |
+
a plain-text summary, and a link to the full advisory.
|
| 386 |
+
|
| 387 |
+
Advisory levels:
|
| 388 |
+
1 = Exercise Normal Precautions
|
| 389 |
+
2 = Exercise Increased Caution
|
| 390 |
+
3 = Reconsider Travel
|
| 391 |
+
4 = Do Not Travel
|
| 392 |
+
|
| 393 |
+
Args:
|
| 394 |
+
country: Country name to look up (e.g. 'Sudan', 'Ukraine', 'Haiti').
|
| 395 |
+
"""
|
| 396 |
+
try:
|
| 397 |
+
resp = requests.get(_ADVISORY_API, timeout=20)
|
| 398 |
+
resp.raise_for_status()
|
| 399 |
+
advisories = resp.json()
|
| 400 |
+
except requests.RequestException as e:
|
| 401 |
+
return f"[Travel Advisory] Request failed: {e}"
|
| 402 |
+
except ValueError:
|
| 403 |
+
return "[Travel Advisory] Could not parse API response as JSON."
|
| 404 |
+
|
| 405 |
+
country_lower = country.lower().strip()
|
| 406 |
+
match = None
|
| 407 |
+
for entry in advisories:
|
| 408 |
+
title = entry.get("Title", "")
|
| 409 |
+
dest = title.split(" - Level ")[0].strip()
|
| 410 |
+
if country_lower in dest.lower():
|
| 411 |
+
match = entry
|
| 412 |
+
break
|
| 413 |
+
|
| 414 |
+
if not match:
|
| 415 |
+
return (
|
| 416 |
+
f"[Travel Advisory] No advisory found for '{country}'. "
|
| 417 |
+
"Check spelling or try the country's common English name."
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
title = match.get("Title", "")
|
| 421 |
+
link = match.get("Link", "")
|
| 422 |
+
published = match.get("Published", match.get("Updated", ""))
|
| 423 |
+
raw_summary = match.get("Summary", "")
|
| 424 |
+
summary = _strip_html(raw_summary)[:250]
|
| 425 |
+
|
| 426 |
+
level_match = re.search(r"Level\s+(\d)", title, re.IGNORECASE)
|
| 427 |
+
level_num = level_match.group(1) if level_match else "Unknown"
|
| 428 |
+
|
| 429 |
+
summary_lower = summary.lower()
|
| 430 |
+
indicators = [
|
| 431 |
+
label for keyword, label in _RISK_KEYWORDS.items()
|
| 432 |
+
if keyword in summary_lower
|
| 433 |
+
]
|
| 434 |
+
|
| 435 |
+
date_str = published[:10] if published else ""
|
| 436 |
+
|
| 437 |
+
lines = [
|
| 438 |
+
f"[Travel Advisory] {title}",
|
| 439 |
+
f"Risk Categories: {', '.join(indicators) if indicators else 'See summary'}",
|
| 440 |
+
f"Published: {date_str}",
|
| 441 |
+
f"Summary: {summary}",
|
| 442 |
+
]
|
| 443 |
+
if link:
|
| 444 |
+
lines.append(f"Full Advisory: {link}")
|
| 445 |
+
return "\n".join(lines)
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
# ---------------------------------------------------------------------------
|
| 449 |
+
# Airspace helpers β EASA CZIBs, AviationWeather SIGMETs, AVWX NOTAMs
|
| 450 |
+
# ---------------------------------------------------------------------------
|
| 451 |
+
|
| 452 |
+
COUNTRY_ICAO_MAP: dict = {
|
| 453 |
+
"ukraine": ["UKBB", "UKKK", "UKLL"],
|
| 454 |
+
"russia": ["UUEE", "UUDD", "ULLI"],
|
| 455 |
+
"sudan": ["HSSS"],
|
| 456 |
+
"myanmar": ["VYYY", "VYBR"],
|
| 457 |
+
"haiti": ["MTPP"],
|
| 458 |
+
"syria": ["OSDI", "OSLK"],
|
| 459 |
+
"iraq": ["ORBI", "ORMM", "ORKK"],
|
| 460 |
+
"libya": ["HLLT", "HLLB"],
|
| 461 |
+
"somalia": ["HCMM"],
|
| 462 |
+
"yemen": ["OYAA", "OYAB"],
|
| 463 |
+
"afghanistan": ["OAKB"],
|
| 464 |
+
"ethiopia": ["HAAB"],
|
| 465 |
+
"nigeria": ["DNMM", "DNKN", "DNAA"],
|
| 466 |
+
"mali": ["GABS", "GAMB"],
|
| 467 |
+
"burkina faso": ["DFFD"],
|
| 468 |
+
"niger": ["DRRN"],
|
| 469 |
+
"mozambique": ["FQMA"],
|
| 470 |
+
"central african republic": ["FEFF"],
|
| 471 |
+
"democratic republic of congo": ["FZAA", "FZNA"],
|
| 472 |
+
"drc": ["FZAA"],
|
| 473 |
+
"israel": ["LLBG", "LLHA"],
|
| 474 |
+
"palestine": ["LVGZ"],
|
| 475 |
+
"iran": ["OIIE", "OIII", "OIMM"],
|
| 476 |
+
"pakistan": ["OPKC", "OPLA", "OPPS"],
|
| 477 |
+
"north korea": ["ZKPY"],
|
| 478 |
+
"venezuela": ["SVMI", "SVMC"],
|
| 479 |
+
"mexico": ["MMMX", "MMGL", "MMMY"],
|
| 480 |
+
"colombia": ["SKBO", "SKCL"],
|
| 481 |
+
"lebanon": ["OLBA"],
|
| 482 |
+
"georgia": ["UGTB"],
|
| 483 |
+
"armenia": ["UDYZ"],
|
| 484 |
+
"azerbaijan": ["UBBB"],
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def _kws(country: str) -> list:
|
| 489 |
+
return [w.lower() for w in country.lower().split() if len(w) > 2]
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def _fetch_easa_czibs(country: str) -> list:
|
| 493 |
+
"""Scrape EASA's live Conflict Zone Information Bulletins table."""
|
| 494 |
+
url = "https://www.easa.europa.eu/en/domains/air-operations/czibs"
|
| 495 |
+
try:
|
| 496 |
+
resp = requests.get(
|
| 497 |
+
url,
|
| 498 |
+
timeout=15,
|
| 499 |
+
headers={"User-Agent": "Mozilla/5.0 (compatible; OSINTBot/1.0)"},
|
| 500 |
+
)
|
| 501 |
+
resp.raise_for_status()
|
| 502 |
+
except requests.RequestException as e:
|
| 503 |
+
return [f"[EASA CZIB] Request failed: {e}"]
|
| 504 |
+
|
| 505 |
+
keywords = _kws(country)
|
| 506 |
+
html_text = resp.text
|
| 507 |
+
rows = re.findall(r"<tr[^>]*>(.*?)</tr>", html_text, re.DOTALL | re.IGNORECASE)
|
| 508 |
+
results = []
|
| 509 |
+
|
| 510 |
+
for row in rows:
|
| 511 |
+
cell_text = re.sub(r"<[^>]+>", " ", row)
|
| 512 |
+
cell_text = re.sub(r"\s+", " ", cell_text).strip()
|
| 513 |
+
if cell_text and any(kw in cell_text.lower() for kw in keywords):
|
| 514 |
+
link_match = re.search(r'href="(/en/domains/air-operations/czibs/[^"]+)"', row)
|
| 515 |
+
link = f"https://www.easa.europa.eu{link_match.group(1)}" if link_match else ""
|
| 516 |
+
entry = f"EASA CZIB | {cell_text}"
|
| 517 |
+
if link:
|
| 518 |
+
entry += f" | {link}"
|
| 519 |
+
results.append(entry)
|
| 520 |
+
|
| 521 |
+
return results if results else [f"[EASA CZIB] No active conflict zone bulletins found for '{country}'."]
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _fetch_sigmets(country: str) -> list:
|
| 525 |
+
"""Fetch active international SIGMETs from AviationWeather.gov."""
|
| 526 |
+
url = "https://aviationweather.gov/api/data/isigmet?format=json"
|
| 527 |
+
try:
|
| 528 |
+
resp = requests.get(url, timeout=15)
|
| 529 |
+
resp.raise_for_status()
|
| 530 |
+
sigmets = resp.json()
|
| 531 |
+
except (requests.RequestException, ValueError) as e:
|
| 532 |
+
return [f"[SIGMET] Request failed: {e}"]
|
| 533 |
+
|
| 534 |
+
if not isinstance(sigmets, list):
|
| 535 |
+
return ["[SIGMET] Unexpected response format."]
|
| 536 |
+
|
| 537 |
+
keywords = _kws(country)
|
| 538 |
+
results = []
|
| 539 |
+
|
| 540 |
+
for s in sigmets:
|
| 541 |
+
fir_name = s.get("firName", "")
|
| 542 |
+
raw = s.get("rawSigmet", "")
|
| 543 |
+
searchable = (fir_name + " " + raw).lower()
|
| 544 |
+
if not any(kw in searchable for kw in keywords):
|
| 545 |
+
continue
|
| 546 |
+
|
| 547 |
+
try:
|
| 548 |
+
valid_from = datetime.utcfromtimestamp(s["validTimeFrom"]).strftime("%Y-%m-%d %H:%MZ")
|
| 549 |
+
valid_to = datetime.utcfromtimestamp(s["validTimeTo"]).strftime("%Y-%m-%d %H:%MZ")
|
| 550 |
+
except (KeyError, TypeError, OSError):
|
| 551 |
+
valid_from = valid_to = "?"
|
| 552 |
+
|
| 553 |
+
base = s.get("base") or "SFC"
|
| 554 |
+
top = s.get("top", "?")
|
| 555 |
+
results.append(
|
| 556 |
+
f"SIGMET | {fir_name} | Hazard: {s.get('hazard','?')} {s.get('qualifier','')}"
|
| 557 |
+
f" | Valid: {valid_from} β {valid_to}"
|
| 558 |
+
f" | FL: {base}β{top}"
|
| 559 |
+
f" | {raw[:150]}"
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
return results if results else [f"[SIGMET] No active SIGMETs found for '{country}'."]
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def _fetch_avwx_notams(country: str, token: str) -> list:
|
| 566 |
+
"""Fetch NOTAMs via AVWX for the country's main airports."""
|
| 567 |
+
country_lower = country.lower()
|
| 568 |
+
icao_codes = COUNTRY_ICAO_MAP.get(country_lower)
|
| 569 |
+
if not icao_codes:
|
| 570 |
+
for k, v in COUNTRY_ICAO_MAP.items():
|
| 571 |
+
if country_lower in k or k in country_lower:
|
| 572 |
+
icao_codes = v
|
| 573 |
+
break
|
| 574 |
+
|
| 575 |
+
if not icao_codes:
|
| 576 |
+
return [f"[NOTAM] No ICAO airport codes mapped for '{country}'. Skipping AVWX lookup."]
|
| 577 |
+
|
| 578 |
+
headers = {"Authorization": f"BEARER {token}"}
|
| 579 |
+
results = []
|
| 580 |
+
|
| 581 |
+
for icao in icao_codes[:2]:
|
| 582 |
+
try:
|
| 583 |
+
resp = requests.get(
|
| 584 |
+
f"https://avwx.rest/api/notam/{icao}",
|
| 585 |
+
headers=headers,
|
| 586 |
+
timeout=12,
|
| 587 |
+
)
|
| 588 |
+
if resp.status_code == 401:
|
| 589 |
+
return ["[NOTAM] AVWX token invalid or expired β check AVWX_TOKEN secret."]
|
| 590 |
+
resp.raise_for_status()
|
| 591 |
+
data = resp.json()
|
| 592 |
+
except (requests.RequestException, ValueError) as e:
|
| 593 |
+
results.append(f"[NOTAM] {icao}: {e}")
|
| 594 |
+
continue
|
| 595 |
+
|
| 596 |
+
notam_list = data if isinstance(data, list) else data.get("data", [])
|
| 597 |
+
if not notam_list:
|
| 598 |
+
results.append(f"[NOTAM] {icao}: No active NOTAMs.")
|
| 599 |
+
continue
|
| 600 |
+
|
| 601 |
+
for n in notam_list[:6]:
|
| 602 |
+
raw = (
|
| 603 |
+
n.get("raw")
|
| 604 |
+
or n.get("text", {}).get("repr", "")
|
| 605 |
+
or str(n)
|
| 606 |
+
)[:200]
|
| 607 |
+
results.append(f"NOTAM | {icao} | {raw}")
|
| 608 |
+
|
| 609 |
+
time.sleep(0.2)
|
| 610 |
+
|
| 611 |
+
return results if results else [f"[NOTAM] No NOTAMs returned for '{country}'."]
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
# ---------------------------------------------------------------------------
|
| 615 |
+
# Airspace tool β EASA CZIBs + SIGMETs + AVWX NOTAMs + aviation RSS
|
| 616 |
+
# ---------------------------------------------------------------------------
|
| 617 |
+
|
| 618 |
+
@tool
|
| 619 |
+
def fetch_airspace_status(country: str, max_articles: int = 12) -> str:
|
| 620 |
+
"""
|
| 621 |
+
Fetches airspace disruption intelligence for a given country from three
|
| 622 |
+
structured sources plus aviation news RSS feeds:
|
| 623 |
+
|
| 624 |
+
1. EASA Conflict Zone Information Bulletins (CZIBs) β official EU conflict
|
| 625 |
+
zone airspace warnings (no auth required).
|
| 626 |
+
2. AviationWeather.gov international SIGMETs β active hazards (thunderstorms,
|
| 627 |
+
volcanic ash, turbulence, tropical cyclones) within the country's FIRs
|
| 628 |
+
(no auth required).
|
| 629 |
+
3. AVWX NOTAMs β per-airport notices to airmen for the country's main airports
|
| 630 |
+
(requires optional AVWX_TOKEN Space secret).
|
| 631 |
+
4. Aviation news RSS feeds β filtered for country + airspace keywords.
|
| 632 |
+
|
| 633 |
+
Args:
|
| 634 |
+
country: Country name to query (e.g. 'Ukraine', 'Sudan', 'Libya').
|
| 635 |
+
max_articles: Maximum RSS articles to include (default 12).
|
| 636 |
+
"""
|
| 637 |
+
keywords = _kws(country)
|
| 638 |
+
sections: list = []
|
| 639 |
+
|
| 640 |
+
# 1. EASA CZIBs
|
| 641 |
+
sections.append("=== EASA Conflict Zone Bulletins (CZIBs) ===")
|
| 642 |
+
sections.extend(_fetch_easa_czibs(country))
|
| 643 |
+
|
| 644 |
+
# 2. AviationWeather SIGMETs
|
| 645 |
+
sections.append("\n=== Active SIGMETs (AviationWeather.gov) ===")
|
| 646 |
+
sections.extend(_fetch_sigmets(country))
|
| 647 |
+
|
| 648 |
+
# 3. AVWX NOTAMs (optional)
|
| 649 |
+
avwx_token = os.environ.get("AVWX_TOKEN", "").strip()
|
| 650 |
+
sections.append("\n=== NOTAMs (AVWX) ===")
|
| 651 |
+
if avwx_token:
|
| 652 |
+
sections.extend(_fetch_avwx_notams(country, avwx_token))
|
| 653 |
+
else:
|
| 654 |
+
sections.append("[NOTAM] AVWX_TOKEN not set β skipping NOTAM lookup.")
|
| 655 |
+
|
| 656 |
+
# 4. Aviation RSS news
|
| 657 |
+
articles = []
|
| 658 |
+
feed_errors = []
|
| 659 |
+
|
| 660 |
+
all_sources = {**AVIATION_RSS_SOURCES, **{
|
| 661 |
+
k: v for k, v in RSS_FEED_REGISTRY.items()
|
| 662 |
+
if k in ("bbc_world", "al_jazeera", "france24")
|
| 663 |
+
}}
|
| 664 |
+
|
| 665 |
+
for key, url in all_sources.items():
|
| 666 |
+
if len(articles) >= max_articles:
|
| 667 |
+
break
|
| 668 |
+
try:
|
| 669 |
+
feed = feedparser.parse(url)
|
| 670 |
+
if feed.bozo and not feed.entries:
|
| 671 |
+
feed_errors.append(f"[{key}] Feed error")
|
| 672 |
+
continue
|
| 673 |
+
except Exception as e:
|
| 674 |
+
feed_errors.append(f"[{key}] Exception: {e}")
|
| 675 |
+
continue
|
| 676 |
+
|
| 677 |
+
source_name = feed.feed.get("title", key)
|
| 678 |
+
|
| 679 |
+
for entry in feed.entries[:SCAN_LIMIT]:
|
| 680 |
+
if len(articles) >= max_articles:
|
| 681 |
+
break
|
| 682 |
+
|
| 683 |
+
title = entry.get("title", "").strip()
|
| 684 |
+
raw_summary = entry.get("summary", entry.get("description", ""))
|
| 685 |
+
summary = _strip_html(raw_summary)[:300]
|
| 686 |
+
published = entry.get("published", entry.get("updated", ""))
|
| 687 |
+
link = entry.get("link", "")
|
| 688 |
+
searchable = (title + " " + summary).lower()
|
| 689 |
+
|
| 690 |
+
if not (
|
| 691 |
+
any(kw in searchable for kw in keywords)
|
| 692 |
+
and any(sig in searchable for sig in AIRSPACE_SIGNALS)
|
| 693 |
+
):
|
| 694 |
+
continue
|
| 695 |
+
|
| 696 |
+
articles.append({
|
| 697 |
+
"source_name": source_name,
|
| 698 |
+
"published": published,
|
| 699 |
+
"title": title,
|
| 700 |
+
"summary": summary,
|
| 701 |
+
"url": link,
|
| 702 |
+
})
|
| 703 |
+
|
| 704 |
+
time.sleep(0.3)
|
| 705 |
+
|
| 706 |
+
sections.append(f"\n=== Aviation News ({len(articles)} articles) ===")
|
| 707 |
+
if articles:
|
| 708 |
+
for i, a in enumerate(articles, 1):
|
| 709 |
+
sections.append(
|
| 710 |
+
f"[{i}] {a['source_name']} | {a['published']}\n"
|
| 711 |
+
f" Title: {a['title']}\n"
|
| 712 |
+
f" Summary: {a['summary']}\n"
|
| 713 |
+
f" URL: {a['url']}"
|
| 714 |
+
)
|
| 715 |
+
else:
|
| 716 |
+
sections.append(f"No aviation news matched for '{country}'.")
|
| 717 |
+
|
| 718 |
+
if feed_errors:
|
| 719 |
+
sections.append(f"\n[Feed warnings: {'; '.join(feed_errors)}]")
|
| 720 |
+
|
| 721 |
+
return "\n".join(sections)
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
# ---------------------------------------------------------------------------
|
| 725 |
+
# Helper tool
|
| 726 |
+
# ---------------------------------------------------------------------------
|
| 727 |
+
|
| 728 |
+
@tool
|
| 729 |
+
def list_available_sources() -> str:
|
| 730 |
+
"""
|
| 731 |
+
Returns a list of all available RSS feed source keys and their URLs.
|
| 732 |
+
|
| 733 |
+
Args: None
|
| 734 |
+
"""
|
| 735 |
+
lines = ["Available RSS sources:"]
|
| 736 |
+
for key, url in RSS_FEED_REGISTRY.items():
|
| 737 |
+
lines.append(f" * {key}: {url}")
|
| 738 |
+
lines.append("\nAviation RSS sources (used by fetch_airspace_status):")
|
| 739 |
+
for key, url in AVIATION_RSS_SOURCES.items():
|
| 740 |
+
lines.append(f" * {key}: {url}")
|
| 741 |
+
lines.append("\nStructured airspace sources (used by fetch_airspace_status):")
|
| 742 |
+
lines.append(" * EASA CZIBs (no auth): https://www.easa.europa.eu/en/domains/air-operations/czibs")
|
| 743 |
+
lines.append(" * AviationWeather SIGMETs (no auth): https://aviationweather.gov/api/data/isigmet")
|
| 744 |
+
lines.append(" * AVWX NOTAMs (requires AVWX_TOKEN secret): https://avwx.rest")
|
| 745 |
+
lines.append("\nACLED is also available for structured armed conflict event data.")
|
| 746 |
+
lines.append("US State Department Travel Advisories are also available via fetch_travel_advisory.")
|
| 747 |
+
return "\n".join(lines)
|