Firemedic15 commited on
Commit
26739ca
·
verified ·
1 Parent(s): 26ac00e

Upload 6 files

Browse files
Files changed (5) hide show
  1. app.py +46 -15
  2. brief.py +81 -1
  3. export.py +28 -20
  4. requirements.txt +1 -0
  5. tools.py +105 -1
app.py CHANGED
@@ -3,26 +3,26 @@ app.py — Multi-source OSINT Analyst Space
3
  Agentic loop powered by smolagents + HuggingFace Inference API.
4
 
5
  Required Space Secrets:
6
- ACLED_USERNAME
7
- ACLED_PASSWORD
8
- ACLED_API_KEY — from https://developer.acleddata.com
9
- ACLED_EMAIL — email used to register for ACLED access
10
  HF_TOKEN — HuggingFace token (for Inference API, set automatically in Spaces)
11
  """
12
 
13
  import os
14
  from datetime import datetime
 
15
 
16
  import gradio as gr
17
  from smolagents import InferenceClientModel, ToolCallingAgent
18
 
19
- from tools import fetch_acled_events, fetch_rss_headlines, list_available_sources
20
  from brief import (
21
  BRIEF_PROMPT_SCHEMA,
22
  ThreatBrief,
23
  parse_brief_from_llm,
24
  render_brief_html,
25
  )
 
26
 
27
  # ---------------------------------------------------------------------------
28
  # Model + Agent setup
@@ -36,9 +36,9 @@ def build_agent() -> ToolCallingAgent:
36
  token=os.environ.get("HF_TOKEN"),
37
  )
38
  agent = ToolCallingAgent(
39
- tools=[fetch_acled_events, fetch_rss_headlines, list_available_sources],
40
  model=model,
41
- max_steps=8,
42
  verbosity_level=1,
43
  )
44
  return agent
@@ -65,14 +65,15 @@ def run_analysis(
65
  rss_sources: list,
66
  days_back: int,
67
  progress=gr.Progress(),
68
- ) -> tuple[str, str]:
69
  """
70
  Runs the agentic OSINT analysis loop and returns:
71
  - Structured HTML threat brief
72
  - Raw agent trace for transparency
 
73
  """
74
  if not country.strip():
75
- return "<p style='color:red'>Please enter a country or region.</p>", ""
76
 
77
  progress(0.1, desc="Initializing agent...")
78
 
@@ -84,8 +85,9 @@ Conduct an OSINT threat assessment for: {country}
84
  Instructions:
85
  1. Fetch ACLED armed conflict events for '{country}' over the last {days_back} days.
86
  2. Fetch recent RSS news headlines related to '{country}' from these sources: {sources_str}.
87
- 3. Analyze all collected data carefully.
88
- 4. Produce your final output as ONLY a JSON threat brief matching this schema:
 
89
 
90
  {BRIEF_PROMPT_SCHEMA}
91
 
@@ -106,7 +108,7 @@ Today's date: {datetime.utcnow().strftime('%Y-%m-%d')}
106
  or model timeout. Check Space secrets and try again.</em>
107
  </div>
108
  """
109
- return error_html, str(e)
110
 
111
  progress(0.85, desc="Parsing intelligence brief...")
112
 
@@ -134,7 +136,7 @@ Today's date: {datetime.utcnow().strftime('%Y-%m-%d')}
134
  trace_lines.append(str(raw_output))
135
 
136
  progress(1.0, desc="Done.")
137
- return html_output, "\n".join(trace_lines)
138
 
139
 
140
  # ---------------------------------------------------------------------------
@@ -168,11 +170,13 @@ footer { display: none !important; }
168
 
169
  with gr.Blocks(title="OSINT Threat Analyst", css=CSS, theme=gr.themes.Soft()) as demo:
170
 
 
 
171
  gr.HTML("""
172
  <div style="text-align:center;padding:20px 0 10px 0">
173
  <h1 style="font-size:2em;margin:0">🌐 OSINT Threat Analyst</h1>
174
  <p style="color:#666;margin:6px 0 0 0">
175
- Agentic multi-source intelligence briefing · ACLED + RSS · Powered by HuggingFace
176
  </p>
177
  </div>
178
  """)
@@ -229,6 +233,20 @@ with gr.Blocks(title="OSINT Threat Analyst", css=CSS, theme=gr.themes.Soft()) as
229
  placeholder="Agent reasoning and tool calls will appear here...",
230
  )
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  gr.Examples(
233
  examples=EXAMPLE_QUERIES,
234
  inputs=[country_input, rss_sources, days_back],
@@ -238,7 +256,20 @@ with gr.Blocks(title="OSINT Threat Analyst", css=CSS, theme=gr.themes.Soft()) as
238
  analyze_btn.click(
239
  fn=run_analysis,
240
  inputs=[country_input, rss_sources, days_back],
241
- outputs=[brief_output, trace_output],
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  )
243
 
244
  gr.HTML("""
 
3
  Agentic loop powered by smolagents + HuggingFace Inference API.
4
 
5
  Required Space Secrets:
6
+ ACLED_USERNAME — your myACLED email address (from https://developer.acleddata.com)
7
+ ACLED_PASSWORD — your myACLED password
 
 
8
  HF_TOKEN — HuggingFace token (for Inference API, set automatically in Spaces)
9
  """
10
 
11
  import os
12
  from datetime import datetime
13
+ from typing import Optional
14
 
15
  import gradio as gr
16
  from smolagents import InferenceClientModel, ToolCallingAgent
17
 
18
+ from tools import fetch_acled_events, fetch_rss_headlines, list_available_sources, fetch_travel_advisory
19
  from brief import (
20
  BRIEF_PROMPT_SCHEMA,
21
  ThreatBrief,
22
  parse_brief_from_llm,
23
  render_brief_html,
24
  )
25
+ from export import generate_pdf
26
 
27
  # ---------------------------------------------------------------------------
28
  # Model + Agent setup
 
36
  token=os.environ.get("HF_TOKEN"),
37
  )
38
  agent = ToolCallingAgent(
39
+ tools=[fetch_acled_events, fetch_rss_headlines, list_available_sources, fetch_travel_advisory],
40
  model=model,
41
+ max_steps=15,
42
  verbosity_level=1,
43
  )
44
  return agent
 
65
  rss_sources: list,
66
  days_back: int,
67
  progress=gr.Progress(),
68
+ ) -> tuple:
69
  """
70
  Runs the agentic OSINT analysis loop and returns:
71
  - Structured HTML threat brief
72
  - Raw agent trace for transparency
73
+ - ThreatBrief object (stored in gr.State for PDF export)
74
  """
75
  if not country.strip():
76
+ return "<p style='color:red'>Please enter a country or region.</p>", "", None
77
 
78
  progress(0.1, desc="Initializing agent...")
79
 
 
85
  Instructions:
86
  1. Fetch ACLED armed conflict events for '{country}' over the last {days_back} days.
87
  2. Fetch recent RSS news headlines related to '{country}' from these sources: {sources_str}.
88
+ 3. REQUIRED You MUST call fetch_travel_advisory for '{country}' before writing your final answer. Include the result in the travel_advisory fields even if the level is Unknown.
89
+ 4. Analyze all collected data carefully.
90
+ 5. Produce your final output as ONLY a JSON threat brief matching this schema:
91
 
92
  {BRIEF_PROMPT_SCHEMA}
93
 
 
108
  or model timeout. Check Space secrets and try again.</em>
109
  </div>
110
  """
111
+ return error_html, str(e), None
112
 
113
  progress(0.85, desc="Parsing intelligence brief...")
114
 
 
136
  trace_lines.append(str(raw_output))
137
 
138
  progress(1.0, desc="Done.")
139
+ return html_output, "\n".join(trace_lines), brief
140
 
141
 
142
  # ---------------------------------------------------------------------------
 
170
 
171
  with gr.Blocks(title="OSINT Threat Analyst", css=CSS, theme=gr.themes.Soft()) as demo:
172
 
173
+ brief_state = gr.State(None)
174
+
175
  gr.HTML("""
176
  <div style="text-align:center;padding:20px 0 10px 0">
177
  <h1 style="font-size:2em;margin:0">🌐 OSINT Threat Analyst</h1>
178
  <p style="color:#666;margin:6px 0 0 0">
179
+ Agentic multi-source intelligence briefing · ACLED + RSS · State Dept advisories · Powered by HuggingFace
180
  </p>
181
  </div>
182
  """)
 
233
  placeholder="Agent reasoning and tool calls will appear here...",
234
  )
235
 
236
+ with gr.Row():
237
+ export_btn = gr.Button(
238
+ "📄 Export PDF Report",
239
+ variant="secondary",
240
+ interactive=False,
241
+ scale=1,
242
+ )
243
+
244
+ pdf_file = gr.File(
245
+ label="📥 Download PDF Report",
246
+ visible=False,
247
+ interactive=False,
248
+ )
249
+
250
  gr.Examples(
251
  examples=EXAMPLE_QUERIES,
252
  inputs=[country_input, rss_sources, days_back],
 
256
  analyze_btn.click(
257
  fn=run_analysis,
258
  inputs=[country_input, rss_sources, days_back],
259
+ outputs=[brief_output, trace_output, brief_state],
260
+ ).then(
261
+ fn=lambda b: gr.update(interactive=b is not None),
262
+ inputs=[brief_state],
263
+ outputs=[export_btn],
264
+ )
265
+
266
+ export_btn.click(
267
+ fn=lambda b: generate_pdf(b) if b is not None else None,
268
+ inputs=[brief_state],
269
+ outputs=[pdf_file],
270
+ ).then(
271
+ fn=lambda: gr.update(visible=True),
272
+ outputs=[pdf_file],
273
  )
274
 
275
  gr.HTML("""
brief.py CHANGED
@@ -42,6 +42,11 @@ class ThreatBrief:
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
 
46
  def to_dict(self) -> dict:
47
  d = asdict(self)
@@ -90,7 +95,14 @@ produce a threat brief as a JSON object with EXACTLY these fields:
90
  "key_findings": ["<finding1>", "<finding2>", "<finding3>"],
91
  "indicators_of_escalation": ["<indicator1>", "<indicator2>"],
92
  "recommended_watch_items": ["<watch_item1>", "<watch_item2>"],
93
- "source_types_used": ["ACLED", "RSS"],
 
 
 
 
 
 
 
94
  "notable_news": [
95
  {
96
  "title": "<article headline>",
@@ -136,6 +148,8 @@ def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
136
  notable=n.get("notable", True),
137
  ))
138
 
 
 
139
  return ThreatBrief(
140
  region=data.get("region", ""),
141
  country=data.get("country", ""),
@@ -152,6 +166,11 @@ def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
152
  recommended_watch_items=data.get("recommended_watch_items", []),
153
  source_types_used=data.get("source_types_used", []),
154
  notable_news=news_items,
 
 
 
 
 
155
  )
156
  except json.JSONDecodeError:
157
  return _fallback_brief(raw_text)
@@ -170,6 +189,56 @@ def _fallback_brief(raw_text: str) -> ThreatBrief:
170
  # HTML Renderer — system dark mode aware via CSS custom properties
171
  # ---------------------------------------------------------------------------
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def render_brief_html(brief: ThreatBrief) -> str:
174
  sev_color = SEVERITY_COLORS.get(brief.severity, "#999")
175
  conf_color = CONFIDENCE_COLORS.get(brief.confidence, "#999")
@@ -380,6 +449,15 @@ def render_brief_html(brief: ThreatBrief) -> str:
380
  .warn-section h4 {{ color: var(--text-primary); margin-top: 0; }}
381
  .warn-section ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
382
 
 
 
 
 
 
 
 
 
 
383
  .brief-footer {{
384
  background: var(--bg-secondary);
385
  border: 1px solid var(--border);
@@ -405,6 +483,8 @@ def render_brief_html(brief: ThreatBrief) -> str:
405
  <span><strong>Fatalities:</strong> {fatalities_str}</span>
406
  </div>
407
 
 
 
408
  <div class="section">
409
  <h3>Analytical Summary</h3>
410
  <p>{brief.narrative_summary or '<em>Not available</em>'}</p>
 
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
+ travel_advisory_level: str = ""
46
+ travel_advisory_level_text: str = ""
47
+ travel_advisory_indicators: List[str] = field(default_factory=list)
48
+ travel_advisory_date: str = ""
49
+ travel_advisory_url: str = ""
50
 
51
  def to_dict(self) -> dict:
52
  d = asdict(self)
 
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", "State Dept Travel Advisory"],
99
+ "travel_advisory": {
100
+ "level": "<1|2|3|4 — just the number, or 'Unknown'>",
101
+ "level_text": "<full level string, e.g. 'Level 3: Reconsider Travel'>",
102
+ "indicators": ["<e.g. Crime>", "<e.g. Terrorism>"],
103
+ "date_updated": "<date string from advisory>",
104
+ "url": "<full URL to the advisory page, or empty string>"
105
+ },
106
  "notable_news": [
107
  {
108
  "title": "<article headline>",
 
148
  notable=n.get("notable", True),
149
  ))
150
 
151
+ ta = data.get("travel_advisory", {})
152
+
153
  return ThreatBrief(
154
  region=data.get("region", ""),
155
  country=data.get("country", ""),
 
166
  recommended_watch_items=data.get("recommended_watch_items", []),
167
  source_types_used=data.get("source_types_used", []),
168
  notable_news=news_items,
169
+ travel_advisory_level=str(ta.get("level", "")),
170
+ travel_advisory_level_text=ta.get("level_text", ""),
171
+ travel_advisory_indicators=ta.get("indicators", []),
172
+ travel_advisory_date=ta.get("date_updated", ""),
173
+ travel_advisory_url=ta.get("url", ""),
174
  )
175
  except json.JSONDecodeError:
176
  return _fallback_brief(raw_text)
 
189
  # HTML Renderer — system dark mode aware via CSS custom properties
190
  # ---------------------------------------------------------------------------
191
 
192
+ def _render_travel_advisory(brief: ThreatBrief) -> str:
193
+ level = brief.travel_advisory_level.strip()
194
+ if not level or level == "Unknown":
195
+ return """
196
+ <div class="section muted-section" style="border-top:none">
197
+ <p class="muted-text">🗺️ US State Department travel advisory not available for this country.</p>
198
+ </div>"""
199
+
200
+ color = ADVISORY_LEVEL_COLORS.get(level, "#999")
201
+ label = ADVISORY_LEVEL_LABELS.get(level, brief.travel_advisory_level_text or f"Level {level}")
202
+ indicators_html = (
203
+ " &nbsp;·&nbsp; ".join(f'<span class="risk-tag">{i}</span>' for i in brief.travel_advisory_indicators)
204
+ if brief.travel_advisory_indicators
205
+ else "<em>None listed</em>"
206
+ )
207
+ link_html = (
208
+ f' &nbsp;<a href="{brief.travel_advisory_url}" target="_blank" style="font-size:0.82em;color:var(--text-link)">Full advisory →</a>'
209
+ if brief.travel_advisory_url else ""
210
+ )
211
+ date_html = f'<span style="color:var(--text-muted);font-size:0.82em">Updated: {brief.travel_advisory_date}</span>' if brief.travel_advisory_date else ""
212
+
213
+ return f"""
214
+ <div class="section" style="border-top:none;border-left:4px solid {color}">
215
+ <h3 style="margin-top:0;margin-bottom:10px">
216
+ 🗺️ US State Dept Travel Advisory
217
+ <span style="background:{color};color:white;padding:2px 12px;border-radius:12px;font-size:0.82em;font-weight:bold;margin-left:8px">
218
+ Level {level}: {label}
219
+ </span>
220
+ {link_html}
221
+ </h3>
222
+ <div style="margin-bottom:6px"><strong>Risk categories:</strong> &nbsp;{indicators_html}</div>
223
+ {date_html}
224
+ </div>"""
225
+
226
+
227
+ ADVISORY_LEVEL_COLORS = {
228
+ "1": "#27AE60",
229
+ "2": "#F39C12",
230
+ "3": "#E67E22",
231
+ "4": "#C0392B",
232
+ }
233
+
234
+ ADVISORY_LEVEL_LABELS = {
235
+ "1": "Exercise Normal Precautions",
236
+ "2": "Exercise Increased Caution",
237
+ "3": "Reconsider Travel",
238
+ "4": "Do Not Travel",
239
+ }
240
+
241
+
242
  def render_brief_html(brief: ThreatBrief) -> str:
243
  sev_color = SEVERITY_COLORS.get(brief.severity, "#999")
244
  conf_color = CONFIDENCE_COLORS.get(brief.confidence, "#999")
 
449
  .warn-section h4 {{ color: var(--text-primary); margin-top: 0; }}
450
  .warn-section ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
451
 
452
+ .risk-tag {{
453
+ background: var(--bg-accent);
454
+ border: 1px solid var(--border);
455
+ color: var(--text-body);
456
+ padding: 1px 8px;
457
+ border-radius: 6px;
458
+ font-size: 0.82em;
459
+ }}
460
+
461
  .brief-footer {{
462
  background: var(--bg-secondary);
463
  border: 1px solid var(--border);
 
483
  <span><strong>Fatalities:</strong> {fatalities_str}</span>
484
  </div>
485
 
486
+ {_render_travel_advisory(brief)}
487
+
488
  <div class="section">
489
  <h3>Analytical Summary</h3>
490
  <p>{brief.narrative_summary or '<em>Not available</em>'}</p>
export.py CHANGED
@@ -134,33 +134,41 @@ def generate_pdf(brief: ThreatBrief) -> str:
134
  pdf.cell(0, 7, _safe(f"Reported fatalities: {fat}"))
135
  pdf.ln(12)
136
 
137
- # ---- State Dept Travel Advisory ----
138
- if brief.travel_advisory_level and brief.travel_advisory_level not in ("", "Unknown"):
139
- _section_heading(pdf, "U.S. State Department Travel Advisory")
140
 
141
- adv_rgb = _ADVISORY_COLORS.get(brief.travel_advisory_level, (150, 150, 150))
142
- level_label = brief.travel_advisory_level_text or f"Level {brief.travel_advisory_level}"
 
 
 
143
 
144
- pdf.set_fill_color(*adv_rgb)
145
- pdf.set_text_color(255, 255, 255)
146
- pdf.set_font("Helvetica", "B", 10)
147
- pdf.cell(0, 8, _safe(f" {level_label}"), fill=True, ln=True)
148
 
149
- pdf.set_text_color(44, 52, 68)
150
- pdf.set_font("Helvetica", "", 9)
151
 
152
- if brief.travel_advisory_indicators:
153
- pdf.multi_cell(0, 5, _safe(f"Risk categories: {', '.join(brief.travel_advisory_indicators)}"))
 
 
154
 
155
- if brief.travel_advisory_date:
156
- pdf.cell(0, 5, _safe(f"Last updated: {brief.travel_advisory_date}"), ln=True)
157
 
158
- if brief.travel_advisory_url:
159
- pdf.set_text_color(37, 99, 235)
160
- pdf.multi_cell(0, 5, _safe(f"Source: {brief.travel_advisory_url}"))
161
- pdf.set_text_color(44, 52, 68)
 
 
 
 
162
 
163
- pdf.ln(3)
164
 
165
  # ---- Analytical Summary ----
166
  _section_heading(pdf, "Analytical Summary")
 
134
  pdf.cell(0, 7, _safe(f"Reported fatalities: {fat}"))
135
  pdf.ln(12)
136
 
137
+ # ---- State Dept Travel Advisory (always rendered) ----
138
+ _section_heading(pdf, "U.S. State Department Travel Advisory")
 
139
 
140
+ level = brief.travel_advisory_level or ""
141
+ level_label = brief.travel_advisory_level_text or (
142
+ f"Level {level}" if level and level not in ("", "Unknown") else "Advisory level not retrieved"
143
+ )
144
+ adv_rgb = _ADVISORY_COLORS.get(level, (120, 120, 120))
145
 
146
+ pdf.set_fill_color(*adv_rgb)
147
+ pdf.set_text_color(255, 255, 255)
148
+ pdf.set_font("Helvetica", "B", 10)
149
+ pdf.cell(0, 8, _safe(f" {level_label}"), fill=True, ln=True)
150
 
151
+ pdf.set_text_color(44, 52, 68)
152
+ pdf.set_font("Helvetica", "", 9)
153
 
154
+ if brief.travel_advisory_indicators:
155
+ pdf.multi_cell(0, 5, _safe(f"Risk categories: {', '.join(brief.travel_advisory_indicators)}"))
156
+ else:
157
+ pdf.cell(0, 5, "Risk categories: See full advisory for details", ln=True)
158
 
159
+ if brief.travel_advisory_date:
160
+ pdf.cell(0, 5, _safe(f"Last updated: {brief.travel_advisory_date}"), ln=True)
161
 
162
+ if brief.travel_advisory_url:
163
+ pdf.set_text_color(37, 99, 235)
164
+ pdf.multi_cell(0, 5, _safe(f"Source: {brief.travel_advisory_url}"))
165
+ pdf.set_text_color(44, 52, 68)
166
+ else:
167
+ pdf.set_text_color(37, 99, 235)
168
+ pdf.cell(0, 5, "Source: https://travel.state.gov/content/travel/en/traveladvisories/traveladvisories.html", ln=True)
169
+ pdf.set_text_color(44, 52, 68)
170
 
171
+ pdf.ln(3)
172
 
173
  # ---- Analytical Summary ----
174
  _section_heading(pdf, "Analytical Summary")
requirements.txt CHANGED
@@ -2,3 +2,4 @@ gradio>=5.23.0
2
  smolagents>=1.10.0
3
  feedparser>=6.0.10
4
  requests>=2.31.0
 
 
2
  smolagents>=1.10.0
3
  feedparser>=6.0.10
4
  requests>=2.31.0
5
+ fpdf2>=2.7.0
tools.py CHANGED
@@ -199,8 +199,21 @@ def fetch_rss_headlines(
199
  al_jazeera, bellingcat, crisis_group, acled_blog, un_news, foreign_policy.
200
  max_articles: Maximum total articles to return across all sources (default 20).
201
  """
 
 
 
 
 
 
 
 
 
 
202
  source_keys = [s.strip() for s in sources.split(",") if s.strip()]
203
- keywords = [w.lower() for w in topic.lower().split() if len(w) > 2]
 
 
 
204
  articles = []
205
  feed_errors = []
206
 
@@ -281,6 +294,97 @@ def fetch_rss_headlines(
281
  return "\n\n".join(lines)
282
 
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  # ---------------------------------------------------------------------------
285
  # Helper tool
286
  # ---------------------------------------------------------------------------
 
199
  al_jazeera, bellingcat, crisis_group, acled_blog, un_news, foreign_policy.
200
  max_articles: Maximum total articles to return across all sources (default 20).
201
  """
202
+ # Common country aliases so searches don't miss alternate names in articles
203
+ _ALIASES = {
204
+ "myanmar": ["myanmar", "burma"],
205
+ "burma": ["myanmar", "burma"],
206
+ "ivory coast": ["ivory coast", "côte d'ivoire"],
207
+ "drc": ["drc", "congo", "democratic republic"],
208
+ "car": ["central african republic", "car"],
209
+ "uae": ["uae", "united arab emirates"],
210
+ }
211
+
212
  source_keys = [s.strip() for s in sources.split(",") if s.strip()]
213
+ base_keywords = [w.lower() for w in topic.lower().split() if len(w) > 2]
214
+ topic_lower = topic.lower().strip()
215
+ extra = _ALIASES.get(topic_lower, [])
216
+ keywords = list(dict.fromkeys(base_keywords + extra)) # deduplicate, preserve order
217
  articles = []
218
  feed_errors = []
219
 
 
294
  return "\n\n".join(lines)
295
 
296
 
297
+ # ---------------------------------------------------------------------------
298
+ # US State Department Travel Advisory Tool
299
+ # ---------------------------------------------------------------------------
300
+
301
+ _ADVISORY_API = "https://cadataapi.state.gov/api/TravelAdvisories"
302
+
303
+ _RISK_KEYWORDS = {
304
+ "crime": "Crime",
305
+ "terrorism": "Terrorism",
306
+ "civil unrest": "Civil Unrest",
307
+ "health": "Health",
308
+ "natural disaster": "Natural Disaster",
309
+ "kidnapping": "Kidnapping",
310
+ "wrongful detention": "Wrongful Detention",
311
+ "piracy": "Piracy",
312
+ "maritime": "Maritime",
313
+ }
314
+
315
+
316
+ @tool
317
+ def fetch_travel_advisory(country: str) -> str:
318
+ """
319
+ Fetches the current US State Department travel advisory for a country
320
+ using the official State Department data API.
321
+ Returns the advisory level (1–4), risk categories, publication date,
322
+ a plain-text summary, and a link to the full advisory.
323
+
324
+ Advisory levels:
325
+ 1 = Exercise Normal Precautions
326
+ 2 = Exercise Increased Caution
327
+ 3 = Reconsider Travel
328
+ 4 = Do Not Travel
329
+
330
+ Args:
331
+ country: Country name to look up (e.g. 'Sudan', 'Ukraine', 'Haiti').
332
+ """
333
+ try:
334
+ resp = requests.get(_ADVISORY_API, timeout=20)
335
+ resp.raise_for_status()
336
+ advisories = resp.json()
337
+ except requests.RequestException as e:
338
+ return f"[Travel Advisory] Request failed: {e}"
339
+ except ValueError:
340
+ return "[Travel Advisory] Could not parse API response as JSON."
341
+
342
+ country_lower = country.lower().strip()
343
+
344
+ match = None
345
+ for entry in advisories:
346
+ title = entry.get("Title", "")
347
+ # Title format: "Country Name - Level N: Description"
348
+ dest = title.split(" - Level ")[0].strip()
349
+ if country_lower in dest.lower():
350
+ match = entry
351
+ break
352
+
353
+ if not match:
354
+ return (
355
+ f"[Travel Advisory] No advisory found for '{country}'. "
356
+ "Check spelling or try the country's common English name."
357
+ )
358
+
359
+ title = match.get("Title", "")
360
+ link = match.get("Link", "")
361
+ published = match.get("Published", match.get("Updated", ""))
362
+ raw_summary = match.get("Summary", "")
363
+ summary = _strip_html(raw_summary)[:500]
364
+
365
+ level_match = re.search(r"Level\s+(\d)", title, re.IGNORECASE)
366
+ level_num = level_match.group(1) if level_match else "Unknown"
367
+
368
+ summary_lower = summary.lower()
369
+ indicators = [
370
+ label for keyword, label in _RISK_KEYWORDS.items()
371
+ if keyword in summary_lower
372
+ ]
373
+
374
+ # Parse ISO timestamp to a readable date
375
+ date_str = published[:10] if published else ""
376
+
377
+ lines = [
378
+ f"[Travel Advisory] {title}",
379
+ f"Risk Categories: {', '.join(indicators) if indicators else 'See summary'}",
380
+ f"Published: {date_str}",
381
+ f"Summary: {summary}",
382
+ ]
383
+ if link:
384
+ lines.append(f"Full Advisory: {link}")
385
+ return "\n".join(lines)
386
+
387
+
388
  # ---------------------------------------------------------------------------
389
  # Helper tool
390
  # ---------------------------------------------------------------------------