Firemedic15 commited on
Commit
2165f5d
Β·
verified Β·
1 Parent(s): 079c60f

Revision on the options

Browse files
Files changed (5) hide show
  1. app.py +447 -253
  2. brief.py +737 -552
  3. export.py +59 -30
  4. map_utils.py +16 -42
  5. 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
- ACLED_API_KEY β€” from https://developer.acleddata.com
7
- ACLED_EMAIL β€” email used to register for ACLED access
8
- HF_TOKEN β€” HuggingFace token (for Inference API, set automatically in Spaces)
9
- """
10
-
11
- import os
12
- from datetime import datetime
13
-
14
- import gradio as gr
15
- from smolagents import InferenceClientModel, ToolCallingAgent
16
-
17
- from tools import fetch_acled_events, fetch_rss_headlines, fetch_airspace_status, list_available_sources
18
- from brief import (
19
- BRIEF_PROMPT_SCHEMA,
20
- ThreatBrief,
21
- parse_brief_from_llm,
22
- render_brief_html,
23
- )
24
-
25
- # ---------------------------------------------------------------------------
26
- # Model + Agent setup
27
- # ---------------------------------------------------------------------------
28
-
29
- MODEL_ID = "Qwen/Qwen2.5-72B-Instruct" # Strong free model on HF Inference
30
-
31
- def build_agent() -> ToolCallingAgent:
32
- model = InferenceClientModel(
33
- model_id=MODEL_ID,
34
- token=os.environ.get("HF_TOKEN"),
35
- )
36
- agent = ToolCallingAgent(
37
- tools=[fetch_acled_events, fetch_rss_headlines, fetch_airspace_status, list_available_sources],
38
- model=model,
39
- max_steps=8,
40
- verbosity_level=1,
41
- )
42
- return agent
43
-
44
-
45
- # ---------------------------------------------------------------------------
46
- # Core analysis function
47
- # ---------------------------------------------------------------------------
48
-
49
- SYSTEM_PROMPT = """You are a professional OSINT intelligence analyst specializing
50
- in geopolitical conflict and security threat assessment. Your job is to:
51
-
52
- 1. Call the available tools to gather data from ACLED, RSS news sources, and airspace feeds.
53
- 2. Collect enough information to assess the security situation, including airspace status.
54
- 3. Synthesize your findings into a structured threat brief.
55
-
56
- Always start by checking what sources are available if needed.
57
- Be thorough β€” use multiple sources before drawing conclusions.
58
- """
59
-
60
-
61
- def run_analysis(
62
- country: str,
63
- rss_sources: list,
64
- days_back: int,
65
- progress=gr.Progress(),
66
- ) -> tuple[str, str]:
67
- """
68
- Runs the agentic OSINT analysis loop and returns:
69
- - Structured HTML threat brief
70
- - Raw agent trace for transparency
71
- """
72
- if not country.strip():
73
- return "<p style='color:red'>Please enter a country or region.</p>", ""
74
-
75
- progress(0.1, desc="Initializing agent...")
76
-
77
- sources_str = ",".join(rss_sources) if rss_sources else "reuters_world,bbc_world"
78
-
79
- task = f"""
80
- Conduct an OSINT threat assessment for: {country}
81
-
82
- Instructions:
83
- 1. Fetch ACLED armed conflict events for '{country}' over the last {days_back} days.
84
- 2. Fetch recent RSS news headlines related to '{country}' from these sources: {sources_str}.
85
- 3. Fetch airspace status for '{country}' using the airspace tool.
86
- 4. Analyze all collected data carefully.
87
- 5. Produce your final output as ONLY a JSON threat brief matching this schema:
88
-
89
- {BRIEF_PROMPT_SCHEMA}
90
-
91
- Today's date: {datetime.utcnow().strftime('%Y-%m-%d')}
92
- """
93
-
94
- agent = build_agent()
95
-
96
- progress(0.2, desc="Agent gathering OSINT data...")
97
-
98
- try:
99
- raw_output = agent.run(task, additional_args={"system_prompt": SYSTEM_PROMPT})
100
- except Exception as e:
101
- error_html = f"""
102
- <div style='padding:20px;background:#fff3f3;border:1px solid #ff0000;border-radius:8px'>
103
- <strong>Analysis failed:</strong> {e}<br><br>
104
- <em>Common causes: ACLED credentials missing, HF Inference API overloaded,
105
- or model timeout. Check Space secrets and try again.</em>
106
- </div>
107
- """
108
- return error_html, str(e)
109
-
110
- progress(0.85, desc="Parsing intelligence brief...")
111
-
112
- # Parse the agent's final output into a structured brief
113
- if isinstance(raw_output, str):
114
- brief = parse_brief_from_llm(raw_output)
115
- else:
116
- brief = ThreatBrief(
117
- narrative_summary=str(raw_output),
118
- severity="Unknown",
119
- confidence="Low",
120
- )
121
-
122
- progress(0.95, desc="Rendering brief...")
123
-
124
- html_output = render_brief_html(brief)
125
-
126
- # Build a plain-text trace for the "Raw Trace" tab
127
- trace_lines = [f"=== OSINT Analysis: {country} ==="]
128
- trace_lines.append(f"Model: {MODEL_ID}")
129
- trace_lines.append(f"Sources: ACLED + Airspace + {sources_str}")
130
- trace_lines.append(f"Days back: {days_back}")
131
- trace_lines.append(f"Date: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
132
- trace_lines.append("\n--- Raw Agent Output ---")
133
- trace_lines.append(str(raw_output))
134
-
135
- progress(1.0, desc="Done.")
136
- return html_output, "\n".join(trace_lines)
137
-
138
-
139
- # ---------------------------------------------------------------------------
140
- # Gradio UI
141
- # ---------------------------------------------------------------------------
142
-
143
- RSS_SOURCE_OPTIONS = [
144
- ("Reuters World", "reuters_world"),
145
- ("BBC World", "bbc_world"),
146
- ("Al Jazeera", "al_jazeera"),
147
- ("Bellingcat", "bellingcat"),
148
- ("Crisis Group", "crisis_group"),
149
- ("ACLED Blog", "acled_blog"),
150
- ("UN News", "un_news"),
151
- ("Foreign Policy", "foreign_policy"),
152
- ]
153
-
154
- EXAMPLE_QUERIES = [
155
- ["Sudan", ["reuters_world", "bbc_world", "al_jazeera"], 14],
156
- ["Myanmar", ["reuters_world", "crisis_group", "bellingcat"], 21],
157
- ["Ukraine", ["reuters_world", "bbc_world", "foreign_policy"], 7],
158
- ["Haiti", ["reuters_world", "al_jazeera", "un_news"], 14],
159
- ]
160
-
161
- CSS = """
162
- .gradio-container { max-width: 1100px !important; margin: auto; }
163
- #analyze-btn { background: #1a1a2e; color: white; }
164
- #analyze-btn:hover { background: #16213e; }
165
- footer { display: none !important; }
166
- """
167
-
168
- with gr.Blocks(title="OSINT Threat Analyst", css=CSS, theme=gr.themes.Soft()) as demo:
169
-
170
- gr.HTML("""
171
- <div style="text-align:center;padding:20px 0 10px 0">
172
- <h1 style="font-size:2em;margin:0">🌐 OSINT Threat Analyst</h1>
173
- <p style="color:#666;margin:6px 0 0 0">
174
- Agentic multi-source intelligence briefing Β· ACLED + RSS Β· Powered by HuggingFace
175
- </p>
176
- </div>
177
- """)
178
-
179
- with gr.Row():
180
- with gr.Column(scale=1):
181
- gr.Markdown("### Configure Analysis")
182
-
183
- country_input = gr.Textbox(
184
- label="Country / Region",
185
- placeholder="e.g. Sudan, Myanmar, Haiti...",
186
- max_lines=1,
187
- )
188
-
189
- rss_sources = gr.CheckboxGroup(
190
- choices=RSS_SOURCE_OPTIONS,
191
- value=["reuters_world", "bbc_world", "al_jazeera"],
192
- label="RSS News Sources",
193
- )
194
-
195
- days_back = gr.Slider(
196
- minimum=7,
197
- maximum=90,
198
- value=14,
199
- step=7,
200
- label="ACLED lookback window (days)",
201
- )
202
-
203
- analyze_btn = gr.Button(
204
- "πŸ” Run Analysis",
205
- variant="primary",
206
- elem_id="analyze-btn",
207
- )
208
-
209
- gr.Markdown(
210
- "**Note:** ACLED requires free API credentials set as Space secrets. "
211
- "[Register here](https://developer.acleddata.com).",
212
- elem_classes=["note"],
213
- )
214
-
215
- with gr.Column(scale=2):
216
- with gr.Tabs():
217
- with gr.Tab("πŸ“‹ Threat Brief"):
218
- brief_output = gr.HTML(
219
- value="<div style='padding:40px;text-align:center;color:#999'>"
220
- "Configure your query and click Run Analysis.</div>"
221
- )
222
-
223
- with gr.Tab("πŸ”Ž Raw Agent Trace"):
224
- trace_output = gr.Textbox(
225
- label="Agent trace",
226
- lines=30,
227
- interactive=False,
228
- placeholder="Agent reasoning and tool calls will appear here...",
229
- )
230
-
231
- gr.Examples(
232
- examples=EXAMPLE_QUERIES,
233
- inputs=[country_input, rss_sources, days_back],
234
- label="Example Queries",
235
- )
236
-
237
- analyze_btn.click(
238
- fn=run_analysis,
239
- inputs=[country_input, rss_sources, days_back],
240
- outputs=[brief_output, trace_output],
241
- )
242
-
243
- gr.HTML("""
244
- <div style="text-align:center;padding:16px;color:#999;font-size:0.8em;margin-top:20px">
245
- ⚠️ AI-generated from open sources. Not for operational use without verification.
246
- Built with <a href="https://github.com/huggingface/smolagents">smolagents</a> Β·
247
- Data: <a href="https://acleddata.com">ACLED</a> + public RSS feeds
248
- </div>
249
- """)
250
-
251
-
252
- if __name__ == "__main__":
253
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- narrative_summary: str = ""
40
- key_findings: List[str] = field(default_factory=list)
41
- indicators_of_escalation: List[str] = field(default_factory=list)
42
- recommended_watch_items: List[str] = field(default_factory=list)
43
- source_types_used: List[str] = field(default_factory=list)
44
- notable_news: List[NewsItem] = field(default_factory=list)
45
- airspace_status: str = ""
46
- airspace_restrictions: List[str] = field(default_factory=list)
47
- no_fly_zones: List[str] = field(default_factory=list)
48
- air_defense_activity: List[str] = field(default_factory=list)
49
- aviation_notes: str = ""
50
-
51
- def to_dict(self) -> dict:
52
- d = asdict(self)
53
- d["notable_news"] = [n.to_dict() for n in self.notable_news]
54
- return d
55
-
56
-
57
- # ---------------------------------------------------------------------------
58
- # Severity / confidence colors
59
- # ---------------------------------------------------------------------------
60
-
61
- SEVERITY_COLORS = {
62
- "Critical": "#C0392B",
63
- "High": "#E67E22",
64
- "Medium": "#F39C12",
65
- "Low": "#27AE60",
66
- "Minimal": "#7F8C8D",
67
- }
68
-
69
- CONFIDENCE_COLORS = {
70
- "High": "#27AE60",
71
- "Medium": "#F39C12",
72
- "Low": "#E74C3C",
73
- }
74
-
75
-
76
- # ---------------------------------------------------------------------------
77
- # Prompt schema β€” tells the LLM exactly what JSON to return
78
- # ---------------------------------------------------------------------------
79
-
80
- BRIEF_PROMPT_SCHEMA = """
81
- You are an OSINT intelligence analyst. Based on the collected source data above,
82
- produce a threat brief as a JSON object with EXACTLY these fields:
83
-
84
- {
85
- "region": "<geopolitical region, e.g. 'Latin America'>",
86
- "country": "<primary country of concern>",
87
- "assessment_date": "<today's date, YYYY-MM-DD>",
88
- "severity": "<one of: Critical | High | Medium | Low | Minimal>",
89
- "confidence": "<one of: High | Medium | Low>",
90
- "primary_actors": ["<actor1>", "<actor2>"],
91
- "event_types": ["<type1>", "<type2>"],
92
- "key_locations": ["<location1>", "<location2>"],
93
- "fatalities_reported": <integer or null>,
94
- "narrative_summary": "<2-4 sentence analytical narrative synthesizing all sources>",
95
- "key_findings": ["<finding1>", "<finding2>", "<finding3>"],
96
- "indicators_of_escalation": ["<indicator1>", "<indicator2>"],
97
- "recommended_watch_items": ["<watch_item1>", "<watch_item2>"],
98
- "source_types_used": ["ACLED", "RSS", "AIRSPACE"],
99
- "notable_news": [
100
- {
101
- "title": "<article headline>",
102
- "source": "<news source name>",
103
- "published": "<publication date>",
104
- "summary": "<1-2 sentence summary of why this article is significant>",
105
- "url": "<article URL>",
106
- "notable": true
107
- }
108
- ],
109
- "airspace_status": "<one of: Open | Restricted | Partially Restricted | Closed | Unknown>",
110
- "airspace_restrictions": ["<restriction or NOTAM description1>", "<restriction2>"],
111
- "no_fly_zones": ["<zone name or description1>", "<zone2>"],
112
- "air_defense_activity": ["<activity description1>", "<activity2>"],
113
- "aviation_notes": "<1-2 sentence summary of overall airspace picture and commercial aviation impact>"
114
- }
115
-
116
- For notable_news: include the 3-6 most significant articles from the RSS results.
117
- Prioritize articles marked as NOTABLE in the RSS output. Write the summary field
118
- in your own analytical words β€” explain WHY the article matters to the threat picture,
119
- not just what it says. If no notable articles were found, use an empty array [].
120
-
121
- For airspace fields: use data from the AIRSPACE tool output. If no airspace data
122
- was retrieved, set airspace_status to "Unknown" and use empty arrays. Be concise
123
- and factual β€” cite specific restrictions, zones, or incidents where available.
124
-
125
- Return ONLY the JSON object. No preamble, no markdown fences.
126
- """
127
-
128
-
129
- # ---------------------------------------------------------------------------
130
- # Parser
131
- # ---------------------------------------------------------------------------
132
-
133
- def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
134
- cleaned = re.sub(r"```json|```", "", raw_text).strip()
135
- match = re.search(r"\{.*\}", cleaned, re.DOTALL)
136
- if not match:
137
- return _fallback_brief(raw_text)
138
-
139
- try:
140
- data = json.loads(match.group())
141
-
142
- news_items = []
143
- for n in data.get("notable_news", []):
144
- news_items.append(NewsItem(
145
- title=n.get("title", ""),
146
- source=n.get("source", ""),
147
- published=n.get("published", ""),
148
- summary=n.get("summary", ""),
149
- url=n.get("url", ""),
150
- notable=n.get("notable", True),
151
- ))
152
-
153
- return ThreatBrief(
154
- region=data.get("region", ""),
155
- country=data.get("country", ""),
156
- assessment_date=data.get("assessment_date", ""),
157
- severity=data.get("severity", "Unknown"),
158
- confidence=data.get("confidence", "Low"),
159
- primary_actors=data.get("primary_actors", []),
160
- event_types=data.get("event_types", []),
161
- key_locations=data.get("key_locations", []),
162
- fatalities_reported=data.get("fatalities_reported"),
163
- narrative_summary=data.get("narrative_summary", raw_text),
164
- key_findings=data.get("key_findings", []),
165
- indicators_of_escalation=data.get("indicators_of_escalation", []),
166
- recommended_watch_items=data.get("recommended_watch_items", []),
167
- source_types_used=data.get("source_types_used", []),
168
- notable_news=news_items,
169
- airspace_status=data.get("airspace_status", "Unknown"),
170
- airspace_restrictions=data.get("airspace_restrictions", []),
171
- no_fly_zones=data.get("no_fly_zones", []),
172
- air_defense_activity=data.get("air_defense_activity", []),
173
- aviation_notes=data.get("aviation_notes", ""),
174
- )
175
- except json.JSONDecodeError:
176
- return _fallback_brief(raw_text)
177
-
178
-
179
- def _fallback_brief(raw_text: str) -> ThreatBrief:
180
- return ThreatBrief(
181
- narrative_summary=raw_text,
182
- severity="Unknown",
183
- confidence="Low",
184
- key_findings=["Structured parsing failed β€” see narrative summary."],
185
- )
186
-
187
-
188
- # ---------------------------------------------------------------------------
189
- # HTML Renderer β€” system dark mode aware via CSS custom properties
190
- # ---------------------------------------------------------------------------
191
-
192
- def render_brief_html(brief: ThreatBrief) -> str:
193
- sev_color = SEVERITY_COLORS.get(brief.severity, "#999")
194
- conf_color = CONFIDENCE_COLORS.get(brief.confidence, "#999")
195
-
196
- def badge(label: str, color: str) -> str:
197
- return (
198
- f'<span style="background:{color};color:white;padding:3px 12px;'
199
- f'border-radius:12px;font-weight:bold;font-size:0.85em">{label}</span>'
200
- )
201
-
202
- def bullet_list(items: list) -> str:
203
- if not items:
204
- return "<li><em>None identified</em></li>"
205
- return "".join(f"<li>{i}</li>" for i in items if i and i != "N/A")
206
-
207
- fatalities_str = (
208
- str(brief.fatalities_reported)
209
- if brief.fatalities_reported is not None
210
- else "Unknown"
211
- )
212
- sources_str = ", ".join(brief.source_types_used) if brief.source_types_used else "N/A"
213
-
214
- # Build notable news cards
215
- if brief.notable_news:
216
- cards = []
217
- for item in brief.notable_news:
218
- link_html = (
219
- f'<a href="{item.url}" target="_blank" class="news-link">Read full article β†’</a>'
220
- if item.url else ""
221
- )
222
- cards.append(f"""
223
- <div class="news-card">
224
- <div class="news-meta">
225
- <span class="news-source-badge">{item.source}</span>
226
- <span class="news-date">{item.published[:25] if item.published else ""}</span>
227
- </div>
228
- <div class="news-title">{item.title}</div>
229
- <div class="news-summary">{item.summary}</div>
230
- {link_html}
231
- </div>""")
232
- news_section = f"""
233
- <div class="section">
234
- <h3 class="section-title">πŸ“° Notable News
235
- <span class="section-count">{len(brief.notable_news)} articles</span>
236
- </h3>
237
- {"".join(cards)}
238
- </div>"""
239
- else:
240
- news_section = """
241
- <div class="section muted-section">
242
- <p class="muted-text">No notable news articles retrieved.</p>
243
- </div>"""
244
-
245
- AIRSPACE_STATUS_COLORS = {
246
- "Open": "#27AE60",
247
- "Restricted": "#E67E22",
248
- "Partially Restricted": "#F39C12",
249
- "Closed": "#C0392B",
250
- "Unknown": "#7F8C8D",
251
- }
252
- as_color = AIRSPACE_STATUS_COLORS.get(brief.airspace_status, "#7F8C8D")
253
-
254
- has_airspace_data = (
255
- brief.airspace_restrictions
256
- or brief.no_fly_zones
257
- or brief.air_defense_activity
258
- or brief.aviation_notes
259
- or (brief.airspace_status and brief.airspace_status != "Unknown")
260
- )
261
-
262
- if has_airspace_data:
263
- airspace_section = f"""
264
- <div class="section airspace-section">
265
- <h3 class="section-title">✈️ Airspace &amp; Aviation Status
266
- <span style="margin-left:10px">{badge(brief.airspace_status or 'Unknown', as_color)}</span>
267
- </h3>
268
- {f'<p class="airspace-notes">{brief.aviation_notes}</p>' if brief.aviation_notes else ""}
269
- <div class="airspace-grid">
270
- <div class="airspace-cell">
271
- <h4>🚫 No-Fly Zones</h4>
272
- <ul>{bullet_list(brief.no_fly_zones)}</ul>
273
- </div>
274
- <div class="airspace-cell">
275
- <h4>⚠️ Active Restrictions / NOTAMs</h4>
276
- <ul>{bullet_list(brief.airspace_restrictions)}</ul>
277
- </div>
278
- <div class="airspace-cell airspace-cell-full">
279
- <h4>πŸ›‘οΈ Air Defense Activity</h4>
280
- <ul>{bullet_list(brief.air_defense_activity)}</ul>
281
- </div>
282
- </div>
283
- </div>"""
284
- else:
285
- airspace_section = """
286
- <div class="section muted-section">
287
- <p class="muted-text">✈️ No airspace restriction data retrieved for this country.</p>
288
- </div>"""
289
-
290
- html = f"""
291
- <style>
292
- .brief-wrap {{
293
- font-family: system-ui, -apple-system, sans-serif;
294
- max-width: 920px;
295
- margin: auto;
296
-
297
- /* Light mode tokens */
298
- --bg-primary: #ffffff;
299
- --bg-secondary: #f4f6f9;
300
- --bg-accent: #f0f3f8;
301
- --bg-warn: #fff8e1;
302
- --bg-news-card: #f8fbff;
303
- --border: #d0d7e2;
304
- --text-primary: #1a1a2e;
305
- --text-body: #2c3444;
306
- --text-muted: #6b7280;
307
- --text-link: #2563eb;
308
- --news-badge-bg: #dbeafe;
309
- --news-badge-fg: #1d4ed8;
310
- --header-bg: #1a1a2e;
311
- --header-text: #ffffff;
312
- }}
313
-
314
- @media (prefers-color-scheme: dark) {{
315
- .brief-wrap {{
316
- --bg-primary: #1e2130;
317
- --bg-secondary: #252836;
318
- --bg-accent: #2a2d3e;
319
- --bg-warn: #2d2a1a;
320
- --bg-news-card: #232638;
321
- --border: #3a3f55;
322
- --text-primary: #e8eaf6;
323
- --text-body: #c5c9db;
324
- --text-muted: #8b91a8;
325
- --text-link: #7eb3f8;
326
- --news-badge-bg: #1e3a5f;
327
- --news-badge-fg: #7eb3f8;
328
- --header-bg: #111527;
329
- --header-text: #ffffff;
330
- }}
331
- }}
332
-
333
- .brief-wrap * {{ box-sizing: border-box; }}
334
-
335
- .brief-header {{
336
- background: var(--header-bg);
337
- color: var(--header-text);
338
- padding: 18px 24px;
339
- border-radius: 8px 8px 0 0;
340
- }}
341
- .brief-header h2 {{ margin: 0 0 4px 0; font-size: 1.3em; }}
342
- .brief-header p {{ margin: 0; opacity: 0.7; font-size: 0.88em; }}
343
-
344
- .status-bar {{
345
- background: var(--bg-accent);
346
- border: 1px solid var(--border);
347
- padding: 12px 24px;
348
- display: flex;
349
- flex-wrap: wrap;
350
- gap: 16px;
351
- align-items: center;
352
- color: var(--text-body);
353
- font-size: 0.9em;
354
- }}
355
- .status-bar strong {{ color: var(--text-primary); }}
356
-
357
- .section {{
358
- background: var(--bg-primary);
359
- border: 1px solid var(--border);
360
- border-top: none;
361
- padding: 18px 24px;
362
- color: var(--text-body);
363
- }}
364
- .section h3, .section h4 {{
365
- color: var(--text-primary);
366
- margin-top: 0;
367
- }}
368
- .section p {{ line-height: 1.8; margin: 0; }}
369
- .section ul {{ padding-left: 20px; line-height: 1.9; margin: 0; }}
370
-
371
- .section-title {{ margin-bottom: 14px; font-size: 1.05em; }}
372
- .section-count {{
373
- font-size: 0.68em;
374
- font-weight: normal;
375
- color: var(--text-muted);
376
- margin-left: 8px;
377
- }}
378
-
379
- .grid-2 {{
380
- display: grid;
381
- grid-template-columns: 1fr 1fr;
382
- border: 1px solid var(--border);
383
- border-top: none;
384
- }}
385
- .grid-cell {{
386
- background: var(--bg-primary);
387
- padding: 16px 20px;
388
- color: var(--text-body);
389
- }}
390
- .grid-cell h4 {{ color: var(--text-primary); margin-top: 0; }}
391
- .grid-cell ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
392
- .grid-cell-border {{ border-right: 1px solid var(--border); }}
393
-
394
- .muted-section {{ background: var(--bg-secondary); }}
395
- .muted-text {{ color: var(--text-muted); font-style: italic; margin: 0; }}
396
-
397
- .news-card {{
398
- background: var(--bg-news-card);
399
- border: 1px solid var(--border);
400
- border-radius: 6px;
401
- padding: 12px 16px;
402
- margin-bottom: 10px;
403
- }}
404
- .news-meta {{ margin-bottom: 6px; }}
405
- .news-source-badge {{
406
- background: var(--news-badge-bg);
407
- color: var(--news-badge-fg);
408
- padding: 2px 8px;
409
- border-radius: 8px;
410
- font-size: 0.78em;
411
- font-weight: 600;
412
- }}
413
- .news-date {{
414
- color: var(--text-muted);
415
- font-size: 0.8em;
416
- margin-left: 8px;
417
- }}
418
- .news-title {{
419
- font-weight: 600;
420
- color: var(--text-primary);
421
- margin-bottom: 5px;
422
- line-height: 1.4;
423
- }}
424
- .news-summary {{
425
- color: var(--text-body);
426
- font-size: 0.9em;
427
- line-height: 1.6;
428
- margin-bottom: 6px;
429
- }}
430
- .news-link {{
431
- color: var(--text-link);
432
- font-size: 0.82em;
433
- text-decoration: none;
434
- }}
435
- .news-link:hover {{ text-decoration: underline; }}
436
-
437
- .airspace-section {{
438
- background: var(--bg-primary);
439
- border: 1px solid var(--border);
440
- border-top: none;
441
- padding: 18px 24px;
442
- color: var(--text-body);
443
- }}
444
- .airspace-notes {{
445
- line-height: 1.7;
446
- margin: 0 0 14px 0;
447
- color: var(--text-body);
448
- }}
449
- .airspace-grid {{
450
- display: grid;
451
- grid-template-columns: 1fr 1fr;
452
- gap: 0;
453
- }}
454
- .airspace-cell {{
455
- padding: 12px 16px 12px 0;
456
- border-right: 1px solid var(--border);
457
- }}
458
- .airspace-cell:last-child {{ border-right: none; padding-right: 0; }}
459
- .airspace-cell-full {{
460
- grid-column: 1 / -1;
461
- border-right: none;
462
- border-top: 1px solid var(--border);
463
- padding-top: 12px;
464
- margin-top: 4px;
465
- }}
466
- .airspace-cell h4 {{
467
- color: var(--text-primary);
468
- margin-top: 0;
469
- margin-bottom: 8px;
470
- font-size: 0.95em;
471
- }}
472
- .airspace-cell ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
473
-
474
- .warn-section {{
475
- background: var(--bg-warn);
476
- border: 1px solid var(--border);
477
- border-top: none;
478
- padding: 16px 24px;
479
- color: var(--text-body);
480
- }}
481
- .warn-section h4 {{ color: var(--text-primary); margin-top: 0; }}
482
- .warn-section ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
483
-
484
- .brief-footer {{
485
- background: var(--bg-secondary);
486
- border: 1px solid var(--border);
487
- border-top: none;
488
- border-radius: 0 0 8px 8px;
489
- padding: 10px 24px;
490
- font-size: 0.78em;
491
- color: var(--text-muted);
492
- }}
493
- </style>
494
-
495
- <div class="brief-wrap">
496
-
497
- <div class="brief-header">
498
- <h2>πŸ›‘οΈ OSINT Threat Brief</h2>
499
- <p>{brief.country or 'Unknown'} &nbsp;|&nbsp; {brief.region} &nbsp;|&nbsp; {brief.assessment_date}</p>
500
- </div>
501
-
502
- <div class="status-bar">
503
- <span><strong>Severity:</strong> &nbsp;{badge(brief.severity, sev_color)}</span>
504
- <span><strong>Confidence:</strong> &nbsp;{badge(brief.confidence, conf_color)}</span>
505
- <span><strong>Sources:</strong> {sources_str}</span>
506
- <span><strong>Fatalities:</strong> {fatalities_str}</span>
507
- </div>
508
-
509
- <div class="section">
510
- <h3>Analytical Summary</h3>
511
- <p>{brief.narrative_summary or '<em>Not available</em>'}</p>
512
- </div>
513
-
514
- <div class="grid-2">
515
- <div class="grid-cell grid-cell-border">
516
- <h4>πŸ” Key Findings</h4>
517
- <ul>{bullet_list(brief.key_findings)}</ul>
518
- </div>
519
- <div class="grid-cell">
520
- <h4>⚠️ Escalation Indicators</h4>
521
- <ul>{bullet_list(brief.indicators_of_escalation)}</ul>
522
- </div>
523
- </div>
524
-
525
- <div class="grid-2">
526
- <div class="grid-cell grid-cell-border" style="border-top:1px solid var(--border)">
527
- <h4>🎭 Primary Actors</h4>
528
- <ul>{bullet_list(brief.primary_actors)}</ul>
529
- </div>
530
- <div class="grid-cell" style="border-top:1px solid var(--border)">
531
- <h4>πŸ“ Key Locations</h4>
532
- <ul>{bullet_list(brief.key_locations)}</ul>
533
- </div>
534
- </div>
535
-
536
- {news_section}
537
-
538
- {airspace_section}
539
-
540
- <div class="warn-section">
541
- <h4>πŸ“‘ Recommended Watch Items</h4>
542
- <ul>{bullet_list(brief.recommended_watch_items)}</ul>
543
- </div>
544
-
545
- <div class="brief-footer">
546
- Event types: {', '.join(brief.event_types) or 'N/A'} &nbsp;|&nbsp;
547
- <em>AI-generated from open sources. Verify before operational use.</em>
548
- </div>
549
-
550
- </div>
551
- """
552
- return html
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ " &nbsp;Β·&nbsp; ".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' &nbsp;<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> &nbsp;{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 &amp; 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'} &nbsp;|&nbsp; {brief.region} &nbsp;|&nbsp; {brief.assessment_date}</p>
669
+ </div>
670
+
671
+ <div class="status-bar">
672
+ <span><strong>Severity:</strong> &nbsp;{badge(brief.severity, sev_color)}</span>
673
+ <span><strong>Confidence:</strong> &nbsp;{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'} &nbsp;|&nbsp;
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 = pdf.get_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}", sev_rgb, pdf.l_margin, badge_y, w=58)
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 # silently skip if image embed fails
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
- # ---- U.S. State Dept Travel Advisory (always shown) ----
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
- fields = [
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, _safe("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,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) # reset draw colour
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
- # ---- Recent Conflict Events (ACLED) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (RSS feed) ----
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. Try adding more RSS sources.", ln=True)
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 β€” Generates a static country map PNG using free OSM tiles,
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 = 2 ** zoom
35
- x = int((lon + 180.0) / 360.0 * n)
36
- y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
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
- """Convert a lat/lon pair to pixel coordinates on the composited canvas."""
42
- n = 2 ** zoom
43
- tx = (lon + 180.0) / 360.0 * n
44
- lat_r = math.radians(lat)
45
- ty = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
46
- px = int((tx - x_min_tile) * TILE_SIZE)
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") # [south, north, west, east]
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 = x_max - x_min + 1
165
- rows = y_max - y_min + 1
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 # map still usable without border
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 address
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
- _token_cache = {
26
- "access_token": None,
27
- "expires_at": 0,
28
- "lock": threading.Lock(),
29
- }
30
-
31
- ACLED_TOKEN_URL = "https://acleddata.com/oauth/token"
32
- ACLED_BASE = "https://acleddata.com/api/acled/read"
33
-
34
-
35
- def _get_acled_token() -> str:
36
- with _token_cache["lock"]:
37
- now = time.time()
38
- if _token_cache["access_token"] and now < _token_cache["expires_at"]:
39
- return _token_cache["access_token"]
40
-
41
- username = os.environ.get("ACLED_USERNAME", "").strip()
42
- password = os.environ.get("ACLED_PASSWORD", "").strip()
43
-
44
- if not username or not password:
45
- raise EnvironmentError(
46
- "ACLED credentials missing. Add ACLED_USERNAME and ACLED_PASSWORD "
47
- "as Space secrets under Settings -> Variables and Secrets."
48
- )
49
-
50
- resp = requests.post(
51
- ACLED_TOKEN_URL,
52
- headers={"Content-Type": "application/x-www-form-urlencoded"},
53
- data={
54
- "username": username,
55
- "password": password,
56
- "grant_type": "password",
57
- "client_id": "acled",
58
- },
59
- timeout=15,
60
- )
61
-
62
- if resp.status_code != 200:
63
- raise EnvironmentError(
64
- f"ACLED token request failed ({resp.status_code}): {resp.text[:200]}"
65
- )
66
-
67
- token_data = resp.json()
68
- _token_cache["access_token"] = token_data["access_token"]
69
- _token_cache["expires_at"] = now + token_data.get("expires_in", 86400) - 300
70
- return _token_cache["access_token"]
71
-
72
-
73
- def _strip_html(text: str) -> str:
74
- """Remove HTML tags and clean up whitespace."""
75
- clean = re.sub(r"<[^>]+>", " ", text)
76
- clean = re.sub(r"\s+", " ", clean)
77
- return clean.strip()
78
-
79
-
80
- # ---------------------------------------------------------------------------
81
- # ACLED Tool
82
- # ---------------------------------------------------------------------------
83
-
84
- @tool
85
- def fetch_acled_events(country: str, days_back: int = 14, limit: int = 25) -> str:
86
- """
87
- Fetches recent armed conflict events from ACLED for a given country.
88
- Returns dates, locations, actor names, event types, and fatality counts.
89
-
90
- Args:
91
- country: Country name to query (e.g. 'Sudan', 'Ukraine', 'Mexico').
92
- days_back: How many days back to search (default 14).
93
- limit: Maximum number of events to return (default 25, max 50).
94
- """
95
- try:
96
- token = _get_acled_token()
97
- except EnvironmentError as e:
98
- return f"[ACLED] Auth error: {e}"
99
- except requests.RequestException as e:
100
- return f"[ACLED] Failed to obtain token: {e}"
101
-
102
- since = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d")
103
-
104
- params = {
105
- "country": country,
106
- "event_date": since,
107
- "event_date_where": ">=",
108
- "limit": min(limit, 50),
109
- "fields": "event_date|event_type|sub_event_type|actor1|actor2|location|admin1|fatalities|notes",
110
- "_format": "json",
111
- }
112
-
113
- headers = {
114
- "Authorization": f"Bearer {token}",
115
- "Content-Type": "application/json",
116
- }
117
-
118
- try:
119
- resp = requests.get(ACLED_BASE, params=params, headers=headers, timeout=15)
120
- resp.raise_for_status()
121
- data = resp.json()
122
- except requests.RequestException as e:
123
- return f"[ACLED] Request failed: {e}"
124
-
125
- if data.get("status") != 200:
126
- return f"[ACLED] API error: {data.get('error', data)}"
127
-
128
- events = data.get("data", [])
129
- if not events:
130
- return f"[ACLED] No events found for '{country}' in the last {days_back} days."
131
-
132
- lines = [f"[ACLED] {len(events)} events in {country} (last {days_back} days):\n"]
133
- total_fatalities = 0
134
-
135
- for ev in events:
136
- fatalities = int(ev.get("fatalities", 0))
137
- total_fatalities += fatalities
138
- actor2_str = f" vs {ev['actor2']}" if ev.get("actor2") else ""
139
- lines.append(
140
- f"* {ev['event_date']} | {ev['event_type']} / {ev.get('sub_event_type', '')} | "
141
- f"{ev.get('location', '?')}, {ev.get('admin1', '?')} | "
142
- f"{ev.get('actor1', '?')}{actor2_str} | "
143
- f"Fatalities: {fatalities} | "
144
- f"Notes: {ev.get('notes', '')[:120]}"
145
- )
146
-
147
- lines.append(f"\nTotal reported fatalities: {total_fatalities}")
148
- return "\n".join(lines)
149
-
150
-
151
- # ---------------------------------------------------------------------------
152
- # RSS Tool β€” returns structured JSON-like records for report inclusion
153
- # ---------------------------------------------------------------------------
154
-
155
- RSS_FEED_REGISTRY = {
156
- "reuters_world": "https://feeds.reuters.com/reuters/worldNews",
157
- "bbc_world": "https://feeds.bbci.co.uk/news/world/rss.xml",
158
- "al_jazeera": "https://www.aljazeera.com/xml/rss/all.xml",
159
- "bellingcat": "https://www.bellingcat.com/feed/",
160
- "crisis_group": "https://www.crisisgroup.org/rss.xml",
161
- "acled_blog": "https://acleddata.com/feed/",
162
- "un_news": "https://news.un.org/feed/subscribe/en/news/feed/rss.xml",
163
- "foreign_policy": "https://foreignpolicy.com/feed/",
164
- }
165
-
166
- AVIATION_RSS_SOURCES = {
167
- "aviation_herald": "https://avherald.com/h?subscribe=rss",
168
- "the_aviationist": "https://theaviationist.com/feed/",
169
- "flight_global": "https://www.flightglobal.com/rss/",
170
- "aviation_week": "https://aviationweek.com/rss/all",
171
- "alert5": "https://alert5.com/feed/",
172
- }
173
-
174
- AIRSPACE_SIGNALS = [
175
- "notam", "no-fly", "no fly", "airspace", "flight ban", "flight restriction",
176
- "fir", "air defense", "air defence", "missile", "anti-aircraft",
177
- "flight advisory", "aviation", "closed airspace", "restricted airspace",
178
- "drone", "uav", "uas", "aircraft", "airline", "airport", "runway",
179
- "air traffic", "eurocontrol", "icao", "overflight", "air corridor",
180
- ]
181
-
182
- SCAN_LIMIT = 50
183
-
184
- # Signal words that bump an article to "notable"
185
- NOTABLE_SIGNALS = [
186
- "killed", "dead", "deaths", "fatalities", "massacre", "attack", "attacked",
187
- "explosion", "bomb", "bombing", "shooting", "gunfire", "clash", "clashes",
188
- "offensive", "invasion", "coup", "crisis", "emergency", "arrest", "arrested",
189
- "protest", "riot", "siege", "hostage", "kidnap", "cartel", "militia",
190
- "sanctions", "airstrike", "drone", "ceasefire", "peace", "agreement",
191
- "earthquake", "flood", "disaster", "outbreak", "epidemic",
192
- ]
193
-
194
-
195
- def _is_notable(title: str, summary: str) -> bool:
196
- """Returns True if the article contains high-signal security/conflict language."""
197
- text = (title + " " + summary).lower()
198
- return any(signal in text for signal in NOTABLE_SIGNALS)
199
-
200
-
201
- @tool
202
- def fetch_rss_headlines(
203
- topic: str,
204
- sources: str = "reuters_world,bbc_world,al_jazeera",
205
- max_articles: int = 20,
206
- ) -> str:
207
- """
208
- Fetches recent RSS news headlines related to a topic or region.
209
- Returns structured article records including title, source, date, summary,
210
- URL, and a 'notable' flag for high-signal security/conflict articles.
211
- The notable flag should be used to select articles for inclusion in the
212
- final threat brief's news section.
213
-
214
- Args:
215
- topic: Keyword or region to filter headlines (e.g. 'Mexico', 'Sudan').
216
- Single keywords work best.
217
- sources: Comma-separated source keys. Available: reuters_world, bbc_world,
218
- al_jazeera, bellingcat, crisis_group, acled_blog, un_news, foreign_policy.
219
- max_articles: Maximum total articles to return across all sources (default 20).
220
- """
221
- source_keys = [s.strip() for s in sources.split(",") if s.strip()]
222
- keywords = [w.lower() for w in topic.lower().split() if len(w) > 2]
223
- articles = []
224
- feed_errors = []
225
-
226
- for key in source_keys:
227
- if len(articles) >= max_articles:
228
- break
229
-
230
- url = RSS_FEED_REGISTRY.get(key)
231
- if not url:
232
- feed_errors.append(f"Unknown source key: '{key}'")
233
- continue
234
-
235
- try:
236
- feed = feedparser.parse(url)
237
- if feed.bozo and not feed.entries:
238
- feed_errors.append(f"[{key}] Feed parse error: {feed.bozo_exception}")
239
- continue
240
- except Exception as e:
241
- feed_errors.append(f"[{key}] Exception: {e}")
242
- continue
243
-
244
- source_name = feed.feed.get("title", key)
245
-
246
- for entry in feed.entries[:SCAN_LIMIT]:
247
- if len(articles) >= max_articles:
248
- break
249
-
250
- title = entry.get("title", "").strip()
251
- raw_summary = entry.get("summary", entry.get("description", ""))
252
- summary = _strip_html(raw_summary)[:300]
253
- published = entry.get("published", entry.get("updated", ""))
254
- link = entry.get("link", "")
255
-
256
- searchable = (title + " " + summary).lower()
257
- if not any(kw in searchable for kw in keywords):
258
- continue
259
-
260
- notable = _is_notable(title, summary)
261
-
262
- articles.append({
263
- "source_key": key,
264
- "source_name": source_name,
265
- "published": published,
266
- "title": title,
267
- "summary": summary,
268
- "url": link,
269
- "notable": notable,
270
- })
271
-
272
- time.sleep(0.3)
273
-
274
- if not articles:
275
- err_detail = "; ".join(feed_errors) if feed_errors else "no entries matched"
276
- return (
277
- f"[RSS] No articles matched '{topic}'. {err_detail}\n"
278
- "Tip: Try a shorter single-word keyword (e.g. 'Mexico' not 'Mexico violence')."
279
- )
280
-
281
- # Format output clearly for the agent
282
- lines = [f"[RSS] {len(articles)} articles found for '{topic}':\n"]
283
- notable_count = sum(1 for a in articles if a["notable"])
284
- lines.append(f"Notable (high-signal) articles: {notable_count} of {len(articles)}\n")
285
-
286
- for i, a in enumerate(articles, 1):
287
- flag = " *** NOTABLE ***" if a["notable"] else ""
288
- lines.append(
289
- f"[{i}] {a['source_name']} | {a['published']}{flag}\n"
290
- f" Title: {a['title']}\n"
291
- f" Summary: {a['summary']}\n"
292
- f" URL: {a['url']}\n"
293
- f" Notable: {a['notable']}"
294
- )
295
-
296
- if feed_errors:
297
- lines.append("\n--- Feed warnings ---")
298
- lines.extend(feed_errors)
299
-
300
- return "\n\n".join(lines)
301
-
302
-
303
- # ---------------------------------------------------------------------------
304
- # Airspace helpers β€” EASA CZIBs, AviationWeather SIGMETs, AVWX NOTAMs
305
- # ---------------------------------------------------------------------------
306
-
307
- # Country name β†’ list of primary ICAO airport codes (for AVWX NOTAM lookups)
308
- COUNTRY_ICAO_MAP: dict[str, list[str]] = {
309
- "ukraine": ["UKBB", "UKKK", "UKLL"],
310
- "russia": ["UUEE", "UUDD", "ULLI"],
311
- "sudan": ["HSSS"],
312
- "myanmar": ["VYYY", "VYBR"],
313
- "haiti": ["MTPP"],
314
- "syria": ["OSDI", "OSLK"],
315
- "iraq": ["ORBI", "ORMM", "ORKK"],
316
- "libya": ["HLLT", "HLLB"],
317
- "somalia": ["HCMM"],
318
- "yemen": ["OYAA", "OYAB"],
319
- "afghanistan": ["OAKB"],
320
- "ethiopia": ["HAAB"],
321
- "nigeria": ["DNMM", "DNKN", "DNAA"],
322
- "mali": ["GABS", "GAMB"],
323
- "burkina faso": ["DFFD"],
324
- "niger": ["DRRN"],
325
- "mozambique": ["FQMA"],
326
- "central african republic": ["FEFF"],
327
- "democratic republic of congo": ["FZAA", "FZNA"],
328
- "drc": ["FZAA"],
329
- "israel": ["LLBG", "LLHA"],
330
- "palestine": ["LVGZ"],
331
- "iran": ["OIIE", "OIII", "OIMM"],
332
- "pakistan": ["OPKC", "OPLA", "OPPS"],
333
- "north korea": ["ZKPY"],
334
- "venezuela": ["SVMI", "SVMC"],
335
- "mexico": ["MMMX", "MMGL", "MMMY"],
336
- "colombia": ["SKBO", "SKCL"],
337
- "lebanon": ["OLBA"],
338
- "georgia": ["UGTB"],
339
- "armenia": ["UDYZ"],
340
- "azerbaijan": ["UBBB"],
341
- }
342
-
343
-
344
- def _keywords(country: str) -> list[str]:
345
- return [w.lower() for w in country.lower().split() if len(w) > 2]
346
-
347
-
348
- def _fetch_easa_czibs(country: str) -> list[str]:
349
- """Scrape EASA's live Conflict Zone Information Bulletins table."""
350
- url = "https://www.easa.europa.eu/en/domains/air-operations/czibs"
351
- try:
352
- resp = requests.get(
353
- url,
354
- timeout=15,
355
- headers={"User-Agent": "Mozilla/5.0 (compatible; OSINTBot/1.0)"},
356
- )
357
- resp.raise_for_status()
358
- except requests.RequestException as e:
359
- return [f"[EASA CZIB] Request failed: {e}"]
360
-
361
- kws = _keywords(country)
362
- html_text = resp.text
363
-
364
- # Each bulletin row: strip tags β†’ plain text; keep rows matching country
365
- rows = re.findall(r"<tr[^>]*>(.*?)</tr>", html_text, re.DOTALL | re.IGNORECASE)
366
- results = []
367
- for row in rows:
368
- cell_text = re.sub(r"<[^>]+>", " ", row)
369
- cell_text = re.sub(r"\s+", " ", cell_text).strip()
370
- if cell_text and any(kw in cell_text.lower() for kw in kws):
371
- # Extract detail page link if present
372
- link_match = re.search(r'href="(/en/domains/air-operations/czibs/[^"]+)"', row)
373
- link = f"https://www.easa.europa.eu{link_match.group(1)}" if link_match else ""
374
- entry = f"EASA CZIB | {cell_text}"
375
- if link:
376
- entry += f" | {link}"
377
- results.append(entry)
378
-
379
- if not results:
380
- return [f"[EASA CZIB] No active conflict zone bulletins found for '{country}'."]
381
- return results
382
-
383
-
384
- def _fetch_sigmets(country: str) -> list[str]:
385
- """Fetch active international SIGMETs from AviationWeather.gov for the country's FIRs."""
386
- url = "https://aviationweather.gov/api/data/isigmet?format=json"
387
- try:
388
- resp = requests.get(url, timeout=15)
389
- resp.raise_for_status()
390
- sigmets = resp.json()
391
- except (requests.RequestException, ValueError) as e:
392
- return [f"[SIGMET] Request failed: {e}"]
393
-
394
- if not isinstance(sigmets, list):
395
- return ["[SIGMET] Unexpected response format."]
396
-
397
- kws = _keywords(country)
398
- results = []
399
- for s in sigmets:
400
- fir_name = s.get("firName", "")
401
- raw = s.get("rawSigmet", "")
402
- searchable = (fir_name + " " + raw).lower()
403
-
404
- if not any(kw in searchable for kw in kws):
405
- continue
406
-
407
- try:
408
- valid_from = datetime.utcfromtimestamp(s["validTimeFrom"]).strftime("%Y-%m-%d %H:%MZ")
409
- valid_to = datetime.utcfromtimestamp(s["validTimeTo"]).strftime("%Y-%m-%d %H:%MZ")
410
- except (KeyError, TypeError, OSError):
411
- valid_from = valid_to = "?"
412
-
413
- base = s.get("base") or "SFC"
414
- top = s.get("top", "?")
415
-
416
- results.append(
417
- f"SIGMET | {fir_name} | Hazard: {s.get('hazard','?')} {s.get('qualifier','')}"
418
- f" | Valid: {valid_from} β†’ {valid_to}"
419
- f" | FL: {base}–{top}"
420
- f" | {raw[:150]}"
421
- )
422
-
423
- if not results:
424
- return [f"[SIGMET] No active SIGMETs found for '{country}'."]
425
- return results
426
-
427
-
428
- def _fetch_avwx_notams(country: str, token: str) -> list[str]:
429
- """Fetch NOTAMs via AVWX for the country's main airports (requires AVWX_TOKEN)."""
430
- country_lower = country.lower()
431
-
432
- # Direct or partial match in the ICAO map
433
- icao_codes = COUNTRY_ICAO_MAP.get(country_lower)
434
- if not icao_codes:
435
- for k, v in COUNTRY_ICAO_MAP.items():
436
- if country_lower in k or k in country_lower:
437
- icao_codes = v
438
- break
439
-
440
- if not icao_codes:
441
- return [f"[NOTAM] No ICAO airport codes mapped for '{country}'. Skipping AVWX lookup."]
442
-
443
- headers = {"Authorization": f"BEARER {token}"}
444
- results = []
445
-
446
- for icao in icao_codes[:2]: # max 2 airports to stay within rate limits
447
- try:
448
- resp = requests.get(
449
- f"https://avwx.rest/api/notam/{icao}",
450
- headers=headers,
451
- timeout=12,
452
- )
453
- if resp.status_code == 401:
454
- return ["[NOTAM] AVWX token invalid or expired β€” check AVWX_TOKEN secret."]
455
- resp.raise_for_status()
456
- data = resp.json()
457
- except (requests.RequestException, ValueError) as e:
458
- results.append(f"[NOTAM] {icao}: {e}")
459
- continue
460
-
461
- notam_list = data if isinstance(data, list) else data.get("data", [])
462
- if not notam_list:
463
- results.append(f"[NOTAM] {icao}: No active NOTAMs.")
464
- continue
465
-
466
- for n in notam_list[:6]:
467
- raw = (
468
- n.get("raw")
469
- or n.get("text", {}).get("repr", "")
470
- or str(n)
471
- )[:200]
472
- results.append(f"NOTAM | {icao} | {raw}")
473
-
474
- time.sleep(0.2)
475
-
476
- return results if results else [f"[NOTAM] No NOTAMs returned for '{country}'."]
477
-
478
-
479
- # ---------------------------------------------------------------------------
480
- # Airspace tool β€” integrates EASA CZIBs + SIGMETs + AVWX NOTAMs + aviation RSS
481
- # ---------------------------------------------------------------------------
482
-
483
- @tool
484
- def fetch_airspace_status(country: str, max_articles: int = 12) -> str:
485
- """
486
- Fetches airspace disruption intelligence for a given country from three
487
- structured sources plus aviation news RSS feeds:
488
-
489
- 1. EASA Conflict Zone Information Bulletins (CZIBs) β€” official EU conflict
490
- zone airspace warnings (no auth required).
491
- 2. AviationWeather.gov international SIGMETs β€” active hazards (thunderstorms,
492
- volcanic ash, turbulence, tropical cyclones) within the country's FIRs
493
- (no auth required).
494
- 3. AVWX NOTAMs β€” per-airport notices to airmen for the country's main airports
495
- (requires optional AVWX_TOKEN Space secret).
496
- 4. Aviation news RSS feeds β€” filtered for country + airspace keywords.
497
-
498
- Returns a combined report suitable for inclusion in a threat brief's airspace
499
- section, covering no-fly zones, active restrictions, and aviation disruptions.
500
-
501
- Args:
502
- country: Country name to query (e.g. 'Ukraine', 'Sudan', 'Libya').
503
- max_articles: Maximum RSS articles to include (default 12).
504
- """
505
- kws = _keywords(country)
506
- sections: list[str] = []
507
-
508
- # -- 1. EASA CZIBs --------------------------------------------------------
509
- czib_results = _fetch_easa_czibs(country)
510
- sections.append("=== EASA Conflict Zone Bulletins (CZIBs) ===")
511
- sections.extend(czib_results)
512
-
513
- # -- 2. AviationWeather SIGMETs -------------------------------------------
514
- sigmet_results = _fetch_sigmets(country)
515
- sections.append("\n=== Active SIGMETs (AviationWeather.gov) ===")
516
- sections.extend(sigmet_results)
517
-
518
- # -- 3. AVWX NOTAMs (optional) --------------------------------------------
519
- avwx_token = os.environ.get("AVWX_TOKEN", "").strip()
520
- if avwx_token:
521
- notam_results = _fetch_avwx_notams(country, avwx_token)
522
- sections.append("\n=== NOTAMs (AVWX) ===")
523
- sections.extend(notam_results)
524
- else:
525
- sections.append("\n=== NOTAMs (AVWX) ===")
526
- sections.append("[NOTAM] AVWX_TOKEN not set β€” skipping NOTAM lookup.")
527
-
528
- # -- 4. Aviation RSS news -------------------------------------------------
529
- articles = []
530
- feed_errors = []
531
-
532
- all_sources = {**AVIATION_RSS_SOURCES, **{
533
- k: v for k, v in RSS_FEED_REGISTRY.items()
534
- if k in ("reuters_world", "bbc_world", "al_jazeera")
535
- }}
536
-
537
- for key, url in all_sources.items():
538
- if len(articles) >= max_articles:
539
- break
540
- try:
541
- feed = feedparser.parse(url)
542
- if feed.bozo and not feed.entries:
543
- feed_errors.append(f"[{key}] Feed error")
544
- continue
545
- except Exception as e:
546
- feed_errors.append(f"[{key}] Exception: {e}")
547
- continue
548
-
549
- source_name = feed.feed.get("title", key)
550
-
551
- for entry in feed.entries[:SCAN_LIMIT]:
552
- if len(articles) >= max_articles:
553
- break
554
-
555
- title = entry.get("title", "").strip()
556
- raw_summary = entry.get("summary", entry.get("description", ""))
557
- summary = _strip_html(raw_summary)[:300]
558
- published = entry.get("published", entry.get("updated", ""))
559
- link = entry.get("link", "")
560
- searchable = (title + " " + summary).lower()
561
-
562
- if not (
563
- any(kw in searchable for kw in kws)
564
- and any(sig in searchable for sig in AIRSPACE_SIGNALS)
565
- ):
566
- continue
567
-
568
- articles.append({
569
- "source_name": source_name,
570
- "published": published,
571
- "title": title,
572
- "summary": summary,
573
- "url": link,
574
- })
575
-
576
- time.sleep(0.3)
577
-
578
- sections.append(f"\n=== Aviation News ({len(articles)} articles) ===")
579
- if articles:
580
- for i, a in enumerate(articles, 1):
581
- sections.append(
582
- f"[{i}] {a['source_name']} | {a['published']}\n"
583
- f" Title: {a['title']}\n"
584
- f" Summary: {a['summary']}\n"
585
- f" URL: {a['url']}"
586
- )
587
- else:
588
- sections.append(f"No aviation news matched for '{country}'.")
589
-
590
- if feed_errors:
591
- sections.append(f"\n[Feed warnings: {'; '.join(feed_errors)}]")
592
-
593
- return "\n".join(sections)
594
-
595
-
596
- # ---------------------------------------------------------------------------
597
- # Helper tool
598
- # ---------------------------------------------------------------------------
599
-
600
- @tool
601
- def list_available_sources() -> str:
602
- """
603
- Returns a list of all available RSS feed source keys and their URLs.
604
-
605
- Args: None
606
- """
607
- lines = ["Available RSS sources:"]
608
- for key, url in RSS_FEED_REGISTRY.items():
609
- lines.append(f" * {key}: {url}")
610
- lines.append("\nAviation RSS sources (used by fetch_airspace_status):")
611
- for key, url in AVIATION_RSS_SOURCES.items():
612
- lines.append(f" * {key}: {url}")
613
- lines.append("\nStructured airspace sources (used by fetch_airspace_status):")
614
- lines.append(" * EASA CZIBs (no auth): https://www.easa.europa.eu/en/domains/air-operations/czibs")
615
- lines.append(" * AviationWeather SIGMETs (no auth): https://aviationweather.gov/api/data/isigmet")
616
- lines.append(" * AVWX NOTAMs (requires AVWX_TOKEN secret): https://avwx.rest")
617
- lines.append("\nACLED is also available for structured armed conflict event data.")
618
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)