Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- app.py +46 -15
- brief.py +81 -1
- export.py +28 -20
- requirements.txt +1 -0
- 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=
|
| 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
|
| 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.
|
| 88 |
-
4.
|
|
|
|
| 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 |
+
" · ".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' <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> {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 |
-
|
| 139 |
-
_section_heading(pdf, "U.S. State Department Travel Advisory")
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
-
|
| 164 |
|
| 165 |
# ---- 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
# ---------------------------------------------------------------------------
|