Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| class NewsItem: | |
| title: str = "" | |
| source: str = "" | |
| published: str = "" | |
| summary: str = "" | |
| url: str = "" | |
| notable: bool = False | |
| def to_dict(self) -> dict: | |
| return asdict(self) | |
| 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 = ( | |
| " Β· ".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' <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> {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 & 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'} | {brief.region} | {brief.assessment_date}</p> | |
| </div> | |
| <div class="status-bar"> | |
| <span><strong>Severity:</strong> {badge(brief.severity, sev_color)}</span> | |
| <span><strong>Confidence:</strong> {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'} | | |
| <em>AI-generated from open sources. Verify before operational use.</em> | |
| </div> | |
| </div> | |
| """ | |
| return html | |