OSINTTool / brief.py
Firemedic15's picture
Revision on the options
2165f5d verified
"""
brief.py β€” Threat brief output schema and HTML renderer.
"""
import json
import re
from dataclasses import dataclass, field, asdict
from typing import List, Optional
# ---------------------------------------------------------------------------
# Data schema
# ---------------------------------------------------------------------------
@dataclass
class NewsItem:
title: str = ""
source: str = ""
published: str = ""
summary: str = ""
url: str = ""
notable: bool = False
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class ThreatBrief:
region: str = ""
country: str = ""
assessment_date: str = ""
severity: str = ""
confidence: str = ""
primary_actors: List[str] = field(default_factory=list)
event_types: List[str] = field(default_factory=list)
key_locations: List[str] = field(default_factory=list)
fatalities_reported: Optional[int] = None
country_summary: str = ""
narrative_summary: str = ""
risk_analysis: str = ""
key_findings: List[str] = field(default_factory=list)
indicators_of_escalation: List[str] = field(default_factory=list)
recommended_watch_items: List[str] = field(default_factory=list)
source_types_used: List[str] = field(default_factory=list)
recent_events: List[str] = field(default_factory=list)
notable_news: List[NewsItem] = field(default_factory=list)
# Travel advisory
travel_advisory_level: str = ""
travel_advisory_level_text: str = ""
travel_advisory_indicators: List[str] = field(default_factory=list)
travel_advisory_date: str = ""
travel_advisory_url: str = ""
# Embassy / consulate
passport_country: str = ""
embassy_name: str = ""
embassy_address: str = ""
embassy_phone: str = ""
embassy_emergency_phone: str = ""
embassy_website: str = ""
embassy_notes: str = ""
# Airspace
airspace_status: str = ""
airspace_restrictions: List[str] = field(default_factory=list)
no_fly_zones: List[str] = field(default_factory=list)
air_defense_activity: List[str] = field(default_factory=list)
aviation_notes: str = ""
def to_dict(self) -> dict:
d = asdict(self)
d["notable_news"] = [n.to_dict() for n in self.notable_news]
return d
# ---------------------------------------------------------------------------
# Severity / confidence / advisory colors
# ---------------------------------------------------------------------------
SEVERITY_COLORS = {
"Critical": "#C0392B",
"High": "#E67E22",
"Medium": "#F39C12",
"Low": "#27AE60",
"Minimal": "#7F8C8D",
}
CONFIDENCE_COLORS = {
"High": "#27AE60",
"Medium": "#F39C12",
"Low": "#E74C3C",
}
ADVISORY_LEVEL_COLORS = {
"1": "#27AE60",
"2": "#F39C12",
"3": "#E67E22",
"4": "#C0392B",
}
ADVISORY_LEVEL_LABELS = {
"1": "Exercise Normal Precautions",
"2": "Exercise Increased Caution",
"3": "Reconsider Travel",
"4": "Do Not Travel",
}
AIRSPACE_STATUS_COLORS = {
"Open": "#27AE60",
"Restricted": "#E67E22",
"Partially Restricted": "#F39C12",
"Closed": "#C0392B",
"Unknown": "#7F8C8D",
}
# ---------------------------------------------------------------------------
# Prompt schema
# ---------------------------------------------------------------------------
BRIEF_PROMPT_SCHEMA = """
You are an OSINT intelligence analyst. Based on the collected source data above,
produce a threat brief as a JSON object with EXACTLY these fields:
{
"region": "<geopolitical region, e.g. 'Latin America'>",
"country": "<primary country of concern>",
"assessment_date": "<today's date, YYYY-MM-DD>",
"severity": "<one of: Critical | High | Medium | Low | Minimal>",
"confidence": "<one of: High | Medium | Low>",
"primary_actors": ["<actor1>", "<actor2>"],
"event_types": ["<type1>", "<type2>"],
"key_locations": ["<location1>", "<location2>"],
"fatalities_reported": <integer or null>,
"country_summary": "<3-5 sentence factual background on the country: geography, population, political system, recent history, and why it is geopolitically significant>",
"narrative_summary": "<2-4 sentence analytical narrative synthesizing all sources>",
"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>",
"key_findings": ["<finding1>", "<finding2>", "<finding3>"],
"indicators_of_escalation": ["<indicator1>", "<indicator2>"],
"recommended_watch_items": ["<watch_item1>", "<watch_item2>"],
"source_types_used": ["ACLED", "RSS", "State Dept Travel Advisory", "AIRSPACE"],
"recent_events": [
"<one-line summary: 'YYYY-MM-DD | LOCATION β€” description, N fatalities'>",
"<repeat for each significant event, up to 10>"
],
"travel_advisory": {
"level": "<1|2|3|4 β€” just the number, or 'Unknown'>",
"level_text": "<full level string, e.g. 'Level 3: Reconsider Travel'>",
"indicators": ["<e.g. Crime>", "<e.g. Terrorism>"],
"date_updated": "<date string from advisory>",
"url": "<full URL to the advisory page, or empty string>"
},
"embassy": {
"name": "<full official name of the embassy or consulate>",
"address": "<street address in the destination country>",
"phone": "<main switchboard phone number including country code>",
"emergency_phone": "<after-hours emergency line including country code>",
"website": "<full URL to the embassy website>",
"notes": "<1-2 sentences: nearest consulate if no embassy, appointment requirements, or emergency procedures>"
},
"airspace_status": "<one of: Open | Restricted | Partially Restricted | Closed | Unknown>",
"airspace_restrictions": ["<restriction or NOTAM description1>", "<restriction2>"],
"no_fly_zones": ["<zone name or description1>", "<zone2>"],
"air_defense_activity": ["<activity description1>", "<activity2>"],
"aviation_notes": "<1-2 sentence summary of overall airspace picture and commercial aviation impact>",
"notable_news": [
{
"title": "<article headline>",
"source": "<news source name>",
"published": "<publication date>",
"summary": "<1-2 sentence analytical summary explaining why this article matters>",
"url": "<article URL>",
"notable": true
}
]
}
For embassy: provide the PASSPORT_COUNTRY's embassy or nearest consulate in the destination country.
If no embassy exists there, name the nearest one in a neighbouring country and explain in notes.
Use your best knowledge of official embassy details; always include the website so the traveller can verify.
For recent_events: list each significant ACLED event as a single line in the format
"DATE | LOCATION β€” description, N fatalities". Include up to 10 events ordered
most-recent first. If ACLED returned no data, use an empty array [].
For airspace fields: use data from the AIRSPACE tool output (EASA CZIBs, SIGMETs, NOTAMs).
If no airspace data was retrieved, set airspace_status to "Unknown" and use empty arrays.
Be concise and factual β€” cite specific restrictions, zones, or incidents where available.
For notable_news: include ALL relevant articles from the RSS results, up to 10.
Write the summary field in your own analytical words β€” explain WHY the article
matters to the threat picture, not just what it says.
If no RSS articles were retrieved, use an empty array [].
Return ONLY the JSON object. No preamble, no markdown fences.
"""
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
cleaned = re.sub(r"```json|```", "", raw_text).strip()
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not match:
return _fallback_brief(raw_text)
try:
data = json.loads(match.group())
news_items = []
for n in data.get("notable_news", []):
news_items.append(NewsItem(
title = n.get("title", ""),
source = n.get("source", ""),
published= n.get("published", ""),
summary = n.get("summary", ""),
url = n.get("url", ""),
notable = n.get("notable", True),
))
ta = data.get("travel_advisory", {})
em = data.get("embassy", {})
return ThreatBrief(
region = data.get("region", ""),
country = data.get("country", ""),
assessment_date = data.get("assessment_date", ""),
severity = data.get("severity", "Unknown"),
confidence = data.get("confidence", "Low"),
primary_actors = data.get("primary_actors", []),
event_types = data.get("event_types", []),
key_locations = data.get("key_locations", []),
fatalities_reported = data.get("fatalities_reported"),
country_summary = data.get("country_summary", ""),
narrative_summary = data.get("narrative_summary", raw_text),
risk_analysis = data.get("risk_analysis", ""),
key_findings = data.get("key_findings", []),
indicators_of_escalation = data.get("indicators_of_escalation", []),
recommended_watch_items = data.get("recommended_watch_items", []),
source_types_used = data.get("source_types_used", []),
recent_events = data.get("recent_events", []),
notable_news = news_items,
travel_advisory_level = str(ta.get("level", "")),
travel_advisory_level_text= ta.get("level_text", ""),
travel_advisory_indicators= ta.get("indicators", []),
travel_advisory_date = ta.get("date_updated", ""),
travel_advisory_url = ta.get("url", ""),
embassy_name = em.get("name", ""),
embassy_address = em.get("address", ""),
embassy_phone = em.get("phone", ""),
embassy_emergency_phone = em.get("emergency_phone", ""),
embassy_website = em.get("website", ""),
embassy_notes = em.get("notes", ""),
airspace_status = data.get("airspace_status", "Unknown"),
airspace_restrictions = data.get("airspace_restrictions", []),
no_fly_zones = data.get("no_fly_zones", []),
air_defense_activity = data.get("air_defense_activity", []),
aviation_notes = data.get("aviation_notes", ""),
)
except json.JSONDecodeError:
return _fallback_brief(raw_text)
def _fallback_brief(raw_text: str) -> ThreatBrief:
return ThreatBrief(
narrative_summary = raw_text,
severity = "Unknown",
confidence = "Low",
key_findings = ["Structured parsing failed β€” see narrative summary."],
)
# ---------------------------------------------------------------------------
# HTML sub-renderers
# ---------------------------------------------------------------------------
def _render_travel_advisory(brief: ThreatBrief) -> str:
level = brief.travel_advisory_level.strip()
if not level or level == "Unknown":
return """
<div class="section muted-section" style="border-top:none">
<p class="muted-text">US State Department travel advisory not available for this country.</p>
</div>"""
color = ADVISORY_LEVEL_COLORS.get(level, "#999")
label = ADVISORY_LEVEL_LABELS.get(level, brief.travel_advisory_level_text or f"Level {level}")
indicators_html = (
" &nbsp;Β·&nbsp; ".join(
f'<span class="risk-tag">{i}</span>' for i in brief.travel_advisory_indicators
)
if brief.travel_advisory_indicators else "<em>None listed</em>"
)
link_html = (
f' &nbsp;<a href="{brief.travel_advisory_url}" target="_blank" '
f'style="font-size:0.82em;color:var(--text-link)">Full advisory β†’</a>'
if brief.travel_advisory_url else ""
)
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 ""
)
return f"""
<div class="section" style="border-top:none;border-left:4px solid {color}">
<h3 style="margin-top:0;margin-bottom:10px">
πŸ—ΊοΈ US State Dept Travel Advisory
<span style="background:{color};color:white;padding:2px 12px;border-radius:12px;font-size:0.82em;font-weight:bold;margin-left:8px">
Level {level}: {label}
</span>
{link_html}
</h3>
<div style="margin-bottom:6px"><strong>Risk categories:</strong> &nbsp;{indicators_html}</div>
{date_html}
</div>"""
def _render_embassy(brief: ThreatBrief) -> str:
if not brief.passport_country:
return ""
if not brief.embassy_name:
return f"""
<div class="section muted-section" style="border-top:none">
<p class="muted-text">Embassy information for {brief.passport_country} passport holders not available.</p>
</div>"""
website_html = (
f'<a href="{brief.embassy_website}" target="_blank" '
f'style="color:var(--text-link);font-size:0.85em">{brief.embassy_website}</a>'
if brief.embassy_website else "<em>Not available</em>"
)
rows = []
if brief.embassy_address:
rows.append(f"<tr><td style='width:160px;color:var(--text-muted);padding:4px 12px 4px 0;vertical-align:top'>Address</td>"
f"<td style='color:var(--text-body)'>{brief.embassy_address}</td></tr>")
if brief.embassy_phone:
rows.append(f"<tr><td style='color:var(--text-muted);padding:4px 12px 4px 0'>Main phone</td>"
f"<td style='color:var(--text-body)'>{brief.embassy_phone}</td></tr>")
if brief.embassy_emergency_phone:
rows.append(f"<tr><td style='color:var(--text-muted);padding:4px 12px 4px 0'>Emergency line</td>"
f"<td style='color:var(--text-body);font-weight:600'>{brief.embassy_emergency_phone}</td></tr>")
rows.append(f"<tr><td style='color:var(--text-muted);padding:4px 12px 4px 0'>Website</td>"
f"<td>{website_html}</td></tr>")
notes_html = (
f'<p style="margin:10px 0 0 0;font-size:0.88em;color:var(--text-muted);font-style:italic">{brief.embassy_notes}</p>'
if brief.embassy_notes else ""
)
return f"""
<div class="section" style="border-top:none;border-left:4px solid #1a56db">
<h3 style="margin-top:0;margin-bottom:10px">
πŸ›οΈ {brief.passport_country} Embassy / Consulate
<span style="font-size:0.75em;font-weight:normal;color:var(--text-muted);margin-left:8px">in {brief.country}</span>
</h3>
<div style="font-weight:600;color:var(--text-primary);margin-bottom:8px">{brief.embassy_name}</div>
<table style="border-collapse:collapse;font-size:0.9em">{"".join(rows)}</table>
{notes_html}
<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>
</div>"""
# ---------------------------------------------------------------------------
# HTML Renderer
# ---------------------------------------------------------------------------
def render_brief_html(brief: ThreatBrief) -> str:
sev_color = SEVERITY_COLORS.get(brief.severity, "#999")
conf_color = CONFIDENCE_COLORS.get(brief.confidence, "#999")
def badge(label: str, color: str) -> str:
return (
f'<span style="background:{color};color:white;padding:3px 12px;'
f'border-radius:12px;font-weight:bold;font-size:0.85em">{label}</span>'
)
def bullet_list(items: list) -> str:
if not items:
return "<li><em>None identified</em></li>"
return "".join(f"<li>{i}</li>" for i in items if i and i != "N/A")
fatalities_str = (
str(brief.fatalities_reported)
if brief.fatalities_reported is not None
else "Unknown"
)
sources_str = ", ".join(brief.source_types_used) if brief.source_types_used else "N/A"
# Recent conflict events
if brief.recent_events:
event_rows = "".join(
f"<tr><td style='padding:4px 8px;border-bottom:1px solid var(--border);"
f"font-family:monospace;font-size:0.82em;color:var(--text-body)'>{e}</td></tr>"
for e in brief.recent_events
)
events_section = f"""
<div class="section">
<h3 class="section-title">βš”οΈ Recent Conflict Events
<span class="section-count">{len(brief.recent_events)} events Β· ACLED</span>
</h3>
<table style="width:100%;border-collapse:collapse">{event_rows}</table>
</div>"""
else:
events_section = """
<div class="section muted-section">
<h3 class="section-title">βš”οΈ Recent Conflict Events</h3>
<p class="muted-text">No ACLED conflict events retrieved. Check ACLED credentials or try a different date range.</p>
</div>"""
# News cards
cards = []
for item in brief.notable_news:
link_html = (
f'<a href="{item.url}" target="_blank" class="news-link">Read full article β†’</a>'
if item.url else ""
)
notable_badge = (
'<span style="background:#fef3c7;color:#92400e;font-size:0.72em;'
'padding:1px 6px;border-radius:8px;margin-left:6px;font-weight:600">NOTABLE</span>'
if item.notable else ""
)
cards.append(f"""
<div class="news-card">
<div class="news-meta">
<span class="news-source-badge">{item.source}</span>
<span class="news-date">{item.published[:25] if item.published else ""}</span>
{notable_badge}
</div>
<div class="news-title">{item.title}</div>
<div class="news-summary">{item.summary}</div>
{link_html}
</div>""")
if cards:
news_section = f"""
<div class="section">
<h3 class="section-title">πŸ“° Recent News
<span class="section-count">{len(brief.notable_news)} articles</span>
</h3>
{"".join(cards)}
</div>"""
else:
news_section = """
<div class="section muted-section">
<h3 class="section-title">πŸ“° Recent News</h3>
<p class="muted-text">No news articles were retrieved. Try adding more RSS sources or broadening the search.</p>
</div>"""
# Airspace section
as_color = AIRSPACE_STATUS_COLORS.get(brief.airspace_status, "#7F8C8D")
has_airspace = (
brief.airspace_restrictions
or brief.no_fly_zones
or brief.air_defense_activity
or brief.aviation_notes
or (brief.airspace_status and brief.airspace_status not in ("", "Unknown"))
)
if has_airspace:
airspace_section = f"""
<div class="section airspace-section">
<h3 class="section-title">✈️ Airspace &amp; Aviation Status
<span style="margin-left:10px">{badge(brief.airspace_status or 'Unknown', as_color)}</span>
</h3>
{f'<p class="airspace-notes">{brief.aviation_notes}</p>' if brief.aviation_notes else ""}
<div class="airspace-grid">
<div class="airspace-cell">
<h4>🚫 No-Fly Zones</h4>
<ul>{bullet_list(brief.no_fly_zones)}</ul>
</div>
<div class="airspace-cell">
<h4>⚠️ Active Restrictions / NOTAMs</h4>
<ul>{bullet_list(brief.airspace_restrictions)}</ul>
</div>
<div class="airspace-cell airspace-cell-full">
<h4>πŸ›‘οΈ Air Defense Activity</h4>
<ul>{bullet_list(brief.air_defense_activity)}</ul>
</div>
</div>
</div>"""
else:
airspace_section = """
<div class="section muted-section">
<p class="muted-text">✈️ No airspace restriction data retrieved for this country.</p>
</div>"""
html = f"""
<style>
.brief-wrap {{
font-family: system-ui, -apple-system, sans-serif;
max-width: 920px;
margin: auto;
--bg-primary: #ffffff;
--bg-secondary: #f4f6f9;
--bg-accent: #f0f3f8;
--bg-warn: #fff8e1;
--bg-news-card: #f8fbff;
--border: #d0d7e2;
--text-primary: #1a1a2e;
--text-body: #2c3444;
--text-muted: #6b7280;
--text-link: #2563eb;
--news-badge-bg: #dbeafe;
--news-badge-fg: #1d4ed8;
--header-bg: #1a1a2e;
--header-text: #ffffff;
}}
@media (prefers-color-scheme: dark) {{
.brief-wrap {{
--bg-primary: #1e2130;
--bg-secondary: #252836;
--bg-accent: #2a2d3e;
--bg-warn: #2d2a1a;
--bg-news-card: #232638;
--border: #3a3f55;
--text-primary: #e8eaf6;
--text-body: #c5c9db;
--text-muted: #8b91a8;
--text-link: #7eb3f8;
--news-badge-bg: #1e3a5f;
--news-badge-fg: #7eb3f8;
--header-bg: #111527;
--header-text: #ffffff;
}}
}}
.brief-wrap * {{ box-sizing: border-box; }}
.brief-header {{
background: var(--header-bg);
color: var(--header-text);
padding: 18px 24px;
border-radius: 8px 8px 0 0;
}}
.brief-header h2 {{ margin: 0 0 4px 0; font-size: 1.3em; }}
.brief-header p {{ margin: 0; opacity: 0.7; font-size: 0.88em; }}
.status-bar {{
background: var(--bg-accent);
border: 1px solid var(--border);
padding: 12px 24px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
color: var(--text-body);
font-size: 0.9em;
}}
.status-bar strong {{ color: var(--text-primary); }}
.section {{
background: var(--bg-primary);
border: 1px solid var(--border);
border-top: none;
padding: 18px 24px;
color: var(--text-body);
}}
.section h3, .section h4 {{ color: var(--text-primary); margin-top: 0; }}
.section p {{ line-height: 1.8; margin: 0; }}
.section ul {{ padding-left: 20px; line-height: 1.9; margin: 0; }}
.section-title {{ margin-bottom: 14px; font-size: 1.05em; }}
.section-count {{
font-size: 0.68em;
font-weight: normal;
color: var(--text-muted);
margin-left: 8px;
}}
.grid-2 {{
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid var(--border);
border-top: none;
}}
.grid-cell {{
background: var(--bg-primary);
padding: 16px 20px;
color: var(--text-body);
}}
.grid-cell h4 {{ color: var(--text-primary); margin-top: 0; }}
.grid-cell ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
.grid-cell-border {{ border-right: 1px solid var(--border); }}
.muted-section {{ background: var(--bg-secondary); }}
.muted-text {{ color: var(--text-muted); font-style: italic; margin: 0; }}
.news-card {{
background: var(--bg-news-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 10px;
}}
.news-meta {{ margin-bottom: 6px; }}
.news-source-badge {{
background: var(--news-badge-bg);
color: var(--news-badge-fg);
padding: 2px 8px;
border-radius: 8px;
font-size: 0.78em;
font-weight: 600;
}}
.news-date {{ color: var(--text-muted); font-size: 0.8em; margin-left: 8px; }}
.news-title {{ font-weight: 600; color: var(--text-primary); margin-bottom: 5px; line-height: 1.4; }}
.news-summary {{ color: var(--text-body); font-size: 0.9em; line-height: 1.6; margin-bottom: 6px; }}
.news-link {{ color: var(--text-link); font-size: 0.82em; text-decoration: none; }}
.news-link:hover {{ text-decoration: underline; }}
.airspace-section {{
background: var(--bg-primary);
border: 1px solid var(--border);
border-top: none;
padding: 18px 24px;
color: var(--text-body);
}}
.airspace-notes {{ line-height: 1.7; margin: 0 0 14px 0; color: var(--text-body); }}
.airspace-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 0; }}
.airspace-cell {{ padding: 12px 16px 12px 0; border-right: 1px solid var(--border); }}
.airspace-cell:last-child {{ border-right: none; padding-right: 0; }}
.airspace-cell-full {{
grid-column: 1 / -1;
border-right: none;
border-top: 1px solid var(--border);
padding-top: 12px;
margin-top: 4px;
}}
.airspace-cell h4 {{ color: var(--text-primary); margin-top: 0; margin-bottom: 8px; font-size: 0.95em; }}
.airspace-cell ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
.warn-section {{
background: var(--bg-warn);
border: 1px solid var(--border);
border-top: none;
padding: 16px 24px;
color: var(--text-body);
}}
.warn-section h4 {{ color: var(--text-primary); margin-top: 0; }}
.warn-section ul {{ padding-left: 18px; line-height: 1.9; margin: 0; }}
.risk-tag {{
background: var(--bg-accent);
border: 1px solid var(--border);
color: var(--text-body);
padding: 1px 8px;
border-radius: 6px;
font-size: 0.82em;
}}
.brief-footer {{
background: var(--bg-secondary);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
padding: 10px 24px;
font-size: 0.78em;
color: var(--text-muted);
}}
</style>
<div class="brief-wrap">
<div class="brief-header">
<h2>πŸ›‘οΈ OSINT Threat Brief</h2>
<p>{brief.country or 'Unknown'} &nbsp;|&nbsp; {brief.region} &nbsp;|&nbsp; {brief.assessment_date}</p>
</div>
<div class="status-bar">
<span><strong>Severity:</strong> &nbsp;{badge(brief.severity, sev_color)}</span>
<span><strong>Confidence:</strong> &nbsp;{badge(brief.confidence, conf_color)}</span>
<span><strong>Sources:</strong> {sources_str}</span>
<span><strong>Fatalities:</strong> {fatalities_str}</span>
</div>
<div class="section">
<h3>🌍 Country Background</h3>
<p>{brief.country_summary or '<em>Not available</em>'}</p>
</div>
{_render_travel_advisory(brief)}
{_render_embassy(brief)}
<div class="section">
<h3>πŸ“‹ Analytical Summary</h3>
<p>{brief.narrative_summary or '<em>Not available</em>'}</p>
</div>
<div class="section" style="border-left:4px solid #C0392B">
<h3 style="margin-top:0;color:#C0392B">πŸ”΄ Risk Assessment</h3>
<p>{brief.risk_analysis or '<em>Risk assessment not available.</em>'}</p>
</div>
<div class="grid-2">
<div class="grid-cell grid-cell-border">
<h4>πŸ” Key Findings</h4>
<ul>{bullet_list(brief.key_findings)}</ul>
</div>
<div class="grid-cell">
<h4>⚠️ Escalation Indicators</h4>
<ul>{bullet_list(brief.indicators_of_escalation)}</ul>
</div>
</div>
<div class="grid-2">
<div class="grid-cell grid-cell-border" style="border-top:1px solid var(--border)">
<h4>🎭 Primary Actors</h4>
<ul>{bullet_list(brief.primary_actors)}</ul>
</div>
<div class="grid-cell" style="border-top:1px solid var(--border)">
<h4>πŸ“ Key Locations</h4>
<ul>{bullet_list(brief.key_locations)}</ul>
</div>
</div>
{airspace_section}
{events_section}
{news_section}
<div class="warn-section">
<h4>πŸ“‘ Recommended Watch Items</h4>
<ul>{bullet_list(brief.recommended_watch_items)}</ul>
</div>
<div class="brief-footer">
Event types: {', '.join(brief.event_types) or 'N/A'} &nbsp;|&nbsp;
<em>AI-generated from open sources. Verify before operational use.</em>
</div>
</div>
"""
return html