Spaces:
Sleeping
Sleeping
Update brief.py
Browse files
brief.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
brief.py β Threat brief output schema and
|
| 3 |
-
The agent synthesizes raw OSINT into this structure.
|
| 4 |
"""
|
| 5 |
|
| 6 |
import json
|
|
@@ -13,13 +12,26 @@ from typing import List, Optional
|
|
| 13 |
# Data schema
|
| 14 |
# ---------------------------------------------------------------------------
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
@dataclass
|
| 17 |
class ThreatBrief:
|
| 18 |
region: str = ""
|
| 19 |
country: str = ""
|
| 20 |
assessment_date: str = ""
|
| 21 |
-
severity: str = ""
|
| 22 |
-
confidence: str = ""
|
| 23 |
primary_actors: List[str] = field(default_factory=list)
|
| 24 |
event_types: List[str] = field(default_factory=list)
|
| 25 |
key_locations: List[str] = field(default_factory=list)
|
|
@@ -29,32 +41,35 @@ class ThreatBrief:
|
|
| 29 |
indicators_of_escalation: List[str] = field(default_factory=list)
|
| 30 |
recommended_watch_items: List[str] = field(default_factory=list)
|
| 31 |
source_types_used: List[str] = field(default_factory=list)
|
|
|
|
| 32 |
|
| 33 |
def to_dict(self) -> dict:
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
# ---------------------------------------------------------------------------
|
| 38 |
-
# Severity
|
| 39 |
# ---------------------------------------------------------------------------
|
| 40 |
|
| 41 |
SEVERITY_COLORS = {
|
| 42 |
-
"Critical": "#
|
| 43 |
-
"High": "#
|
| 44 |
-
"Medium": "#
|
| 45 |
-
"Low": "#
|
| 46 |
-
"Minimal": "#
|
| 47 |
}
|
| 48 |
|
| 49 |
CONFIDENCE_COLORS = {
|
| 50 |
-
"High": "#
|
| 51 |
-
"Medium": "#
|
| 52 |
-
"Low": "#
|
| 53 |
}
|
| 54 |
|
| 55 |
|
| 56 |
# ---------------------------------------------------------------------------
|
| 57 |
-
#
|
| 58 |
# ---------------------------------------------------------------------------
|
| 59 |
|
| 60 |
BRIEF_PROMPT_SCHEMA = """
|
|
@@ -62,41 +77,65 @@ You are an OSINT intelligence analyst. Based on the collected source data above,
|
|
| 62 |
produce a threat brief as a JSON object with EXACTLY these fields:
|
| 63 |
|
| 64 |
{
|
| 65 |
-
"region": "<geopolitical region, e.g. '
|
| 66 |
"country": "<primary country of concern>",
|
| 67 |
"assessment_date": "<today's date, YYYY-MM-DD>",
|
| 68 |
"severity": "<one of: Critical | High | Medium | Low | Minimal>",
|
| 69 |
"confidence": "<one of: High | Medium | Low>",
|
| 70 |
-
"primary_actors": ["<actor1>", "<actor2>"
|
| 71 |
-
"event_types": ["<type1>", "<type2>"
|
| 72 |
-
"key_locations": ["<location1>",
|
| 73 |
"fatalities_reported": <integer or null>,
|
| 74 |
-
"narrative_summary": "<2-4 sentence analytical narrative>",
|
| 75 |
"key_findings": ["<finding1>", "<finding2>", "<finding3>"],
|
| 76 |
-
"indicators_of_escalation": ["<indicator1>",
|
| 77 |
-
"recommended_watch_items": ["<watch_item1>",
|
| 78 |
-
"source_types_used": ["ACLED", "RSS"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
Return ONLY the JSON object. No preamble, no markdown fences.
|
| 82 |
"""
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
|
| 86 |
-
"""
|
| 87 |
-
Attempts to parse a ThreatBrief from LLM output.
|
| 88 |
-
Falls back gracefully if JSON is malformed.
|
| 89 |
-
"""
|
| 90 |
-
# Strip markdown fences if present
|
| 91 |
cleaned = re.sub(r"```json|```", "", raw_text).strip()
|
| 92 |
-
|
| 93 |
-
# Extract first JSON block
|
| 94 |
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
| 95 |
if not match:
|
| 96 |
return _fallback_brief(raw_text)
|
| 97 |
|
| 98 |
try:
|
| 99 |
data = json.loads(match.group())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return ThreatBrief(
|
| 101 |
region=data.get("region", ""),
|
| 102 |
country=data.get("country", ""),
|
|
@@ -112,6 +151,7 @@ def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
|
|
| 112 |
indicators_of_escalation=data.get("indicators_of_escalation", []),
|
| 113 |
recommended_watch_items=data.get("recommended_watch_items", []),
|
| 114 |
source_types_used=data.get("source_types_used", []),
|
|
|
|
| 115 |
)
|
| 116 |
except json.JSONDecodeError:
|
| 117 |
return _fallback_brief(raw_text)
|
|
@@ -127,105 +167,155 @@ def _fallback_brief(raw_text: str) -> ThreatBrief:
|
|
| 127 |
|
| 128 |
|
| 129 |
# ---------------------------------------------------------------------------
|
| 130 |
-
#
|
| 131 |
# ---------------------------------------------------------------------------
|
| 132 |
|
| 133 |
def render_brief_html(brief: ThreatBrief) -> str:
|
| 134 |
-
sev_color
|
| 135 |
conf_color = CONFIDENCE_COLORS.get(brief.confidence, "#999")
|
| 136 |
|
| 137 |
def badge(label: str, color: str) -> str:
|
| 138 |
return (
|
| 139 |
-
f'<span style="background:{color};color:white;padding:
|
| 140 |
f'border-radius:12px;font-weight:bold;font-size:0.85em">{label}</span>'
|
| 141 |
)
|
| 142 |
|
| 143 |
def bullet_list(items: list) -> str:
|
| 144 |
if not items:
|
| 145 |
return "<li><em>None identified</em></li>"
|
| 146 |
-
return "".join(f"<li>{i}</li>" for i in items)
|
| 147 |
|
| 148 |
fatalities_str = (
|
| 149 |
str(brief.fatalities_reported)
|
| 150 |
if brief.fatalities_reported is not None
|
| 151 |
else "Unknown"
|
| 152 |
)
|
| 153 |
-
|
| 154 |
sources_str = ", ".join(brief.source_types_used) if brief.source_types_used else "N/A"
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
html = f"""
|
| 157 |
-
<div style="font-family:system-ui,sans-serif;max-width:
|
| 158 |
|
|
|
|
| 159 |
<div style="background:#1a1a2e;color:white;padding:16px 24px;border-radius:8px 8px 0 0">
|
| 160 |
<h2 style="margin:0 0 4px 0">π‘οΈ OSINT Threat Brief</h2>
|
| 161 |
<p style="margin:0;opacity:0.7;font-size:0.9em">
|
| 162 |
-
{brief.country or 'Unknown
|
| 163 |
{brief.region} |
|
| 164 |
{brief.assessment_date}
|
| 165 |
</p>
|
| 166 |
</div>
|
| 167 |
|
| 168 |
-
<
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
</span>
|
| 172 |
-
<span
|
| 173 |
-
|
| 174 |
-
</span>
|
| 175 |
-
<span>
|
| 176 |
-
<strong>Sources:</strong> {sources_str}
|
| 177 |
-
</span>
|
| 178 |
</div>
|
| 179 |
|
|
|
|
| 180 |
<div style="background:white;padding:20px 24px;border:1px solid #dee2e6;border-top:none">
|
| 181 |
<h3 style="color:#1a1a2e;margin-top:0">Analytical Summary</h3>
|
| 182 |
-
<p style="line-height:1.
|
| 183 |
</div>
|
| 184 |
|
| 185 |
-
<
|
| 186 |
-
|
| 187 |
<div style="padding:16px 20px;border-right:1px solid #dee2e6">
|
| 188 |
<h4 style="color:#1a1a2e;margin-top:0">π Key Findings</h4>
|
| 189 |
-
<ul style="padding-left:18px;line-height:1.
|
| 190 |
{bullet_list(brief.key_findings)}
|
| 191 |
</ul>
|
| 192 |
</div>
|
| 193 |
-
|
| 194 |
<div style="padding:16px 20px">
|
| 195 |
<h4 style="color:#1a1a2e;margin-top:0">β οΈ Escalation Indicators</h4>
|
| 196 |
-
<ul style="padding-left:18px;line-height:1.
|
| 197 |
{bullet_list(brief.indicators_of_escalation)}
|
| 198 |
</ul>
|
| 199 |
</div>
|
|
|
|
| 200 |
|
| 201 |
-
|
|
|
|
|
|
|
| 202 |
<h4 style="color:#1a1a2e;margin-top:0">π Primary Actors</h4>
|
| 203 |
-
<ul style="padding-left:18px;line-height:1.
|
| 204 |
{bullet_list(brief.primary_actors)}
|
| 205 |
</ul>
|
| 206 |
</div>
|
| 207 |
-
|
| 208 |
-
<div style="padding:16px 20px;border-top:1px solid #dee2e6">
|
| 209 |
<h4 style="color:#1a1a2e;margin-top:0">π Key Locations</h4>
|
| 210 |
-
<ul style="padding-left:18px;line-height:1.
|
| 211 |
{bullet_list(brief.key_locations)}
|
| 212 |
</ul>
|
| 213 |
</div>
|
| 214 |
-
|
| 215 |
</div>
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
<div style="background:#fff8e1;padding:16px 24px;border:1px solid #dee2e6;border-top:none">
|
| 218 |
<h4 style="color:#1a1a2e;margin-top:0">π‘ Recommended Watch Items</h4>
|
| 219 |
-
<ul style="padding-left:18px;line-height:1.
|
| 220 |
{bullet_list(brief.recommended_watch_items)}
|
| 221 |
</ul>
|
| 222 |
</div>
|
| 223 |
|
| 224 |
-
<
|
| 225 |
-
|
| 226 |
-
|
| 227 |
Event types: {', '.join(brief.event_types) or 'N/A'} |
|
| 228 |
-
<em>
|
| 229 |
</div>
|
| 230 |
|
| 231 |
</div>
|
|
|
|
| 1 |
"""
|
| 2 |
+
brief.py β Threat brief output schema and HTML renderer.
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import json
|
|
|
|
| 12 |
# Data schema
|
| 13 |
# ---------------------------------------------------------------------------
|
| 14 |
|
| 15 |
+
@dataclass
|
| 16 |
+
class NewsItem:
|
| 17 |
+
title: str = ""
|
| 18 |
+
source: str = ""
|
| 19 |
+
published: str = ""
|
| 20 |
+
summary: str = ""
|
| 21 |
+
url: str = ""
|
| 22 |
+
notable: bool = False
|
| 23 |
+
|
| 24 |
+
def to_dict(self) -> dict:
|
| 25 |
+
return asdict(self)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
@dataclass
|
| 29 |
class ThreatBrief:
|
| 30 |
region: str = ""
|
| 31 |
country: str = ""
|
| 32 |
assessment_date: str = ""
|
| 33 |
+
severity: str = ""
|
| 34 |
+
confidence: str = ""
|
| 35 |
primary_actors: List[str] = field(default_factory=list)
|
| 36 |
event_types: List[str] = field(default_factory=list)
|
| 37 |
key_locations: List[str] = field(default_factory=list)
|
|
|
|
| 41 |
indicators_of_escalation: List[str] = field(default_factory=list)
|
| 42 |
recommended_watch_items: List[str] = field(default_factory=list)
|
| 43 |
source_types_used: List[str] = field(default_factory=list)
|
| 44 |
+
notable_news: List[NewsItem] = field(default_factory=list)
|
| 45 |
|
| 46 |
def to_dict(self) -> dict:
|
| 47 |
+
d = asdict(self)
|
| 48 |
+
d["notable_news"] = [n.to_dict() for n in self.notable_news]
|
| 49 |
+
return d
|
| 50 |
|
| 51 |
|
| 52 |
# ---------------------------------------------------------------------------
|
| 53 |
+
# Severity / confidence colors
|
| 54 |
# ---------------------------------------------------------------------------
|
| 55 |
|
| 56 |
SEVERITY_COLORS = {
|
| 57 |
+
"Critical": "#C0392B",
|
| 58 |
+
"High": "#E67E22",
|
| 59 |
+
"Medium": "#F39C12",
|
| 60 |
+
"Low": "#27AE60",
|
| 61 |
+
"Minimal": "#7F8C8D",
|
| 62 |
}
|
| 63 |
|
| 64 |
CONFIDENCE_COLORS = {
|
| 65 |
+
"High": "#27AE60",
|
| 66 |
+
"Medium": "#F39C12",
|
| 67 |
+
"Low": "#E74C3C",
|
| 68 |
}
|
| 69 |
|
| 70 |
|
| 71 |
# ---------------------------------------------------------------------------
|
| 72 |
+
# Prompt schema β tells the LLM exactly what JSON to return
|
| 73 |
# ---------------------------------------------------------------------------
|
| 74 |
|
| 75 |
BRIEF_PROMPT_SCHEMA = """
|
|
|
|
| 77 |
produce a threat brief as a JSON object with EXACTLY these fields:
|
| 78 |
|
| 79 |
{
|
| 80 |
+
"region": "<geopolitical region, e.g. 'Latin America'>",
|
| 81 |
"country": "<primary country of concern>",
|
| 82 |
"assessment_date": "<today's date, YYYY-MM-DD>",
|
| 83 |
"severity": "<one of: Critical | High | Medium | Low | Minimal>",
|
| 84 |
"confidence": "<one of: High | Medium | Low>",
|
| 85 |
+
"primary_actors": ["<actor1>", "<actor2>"],
|
| 86 |
+
"event_types": ["<type1>", "<type2>"],
|
| 87 |
+
"key_locations": ["<location1>", "<location2>"],
|
| 88 |
"fatalities_reported": <integer or null>,
|
| 89 |
+
"narrative_summary": "<2-4 sentence analytical narrative synthesizing all sources>",
|
| 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>",
|
| 97 |
+
"source": "<news source name>",
|
| 98 |
+
"published": "<publication date>",
|
| 99 |
+
"summary": "<1-2 sentence summary of why this article is significant>",
|
| 100 |
+
"url": "<article URL>",
|
| 101 |
+
"notable": true
|
| 102 |
+
}
|
| 103 |
+
]
|
| 104 |
}
|
| 105 |
|
| 106 |
+
For notable_news: include the 3-6 most significant articles from the RSS results.
|
| 107 |
+
Prioritize articles marked as NOTABLE in the RSS output. Write the summary field
|
| 108 |
+
in your own analytical words β explain WHY the article matters to the threat picture,
|
| 109 |
+
not just what it says. If no notable articles were found, use an empty array [].
|
| 110 |
+
|
| 111 |
Return ONLY the JSON object. No preamble, no markdown fences.
|
| 112 |
"""
|
| 113 |
|
| 114 |
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# Parser
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
def parse_brief_from_llm(raw_text: str) -> ThreatBrief:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
cleaned = re.sub(r"```json|```", "", raw_text).strip()
|
|
|
|
|
|
|
| 121 |
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
| 122 |
if not match:
|
| 123 |
return _fallback_brief(raw_text)
|
| 124 |
|
| 125 |
try:
|
| 126 |
data = json.loads(match.group())
|
| 127 |
+
|
| 128 |
+
news_items = []
|
| 129 |
+
for n in data.get("notable_news", []):
|
| 130 |
+
news_items.append(NewsItem(
|
| 131 |
+
title=n.get("title", ""),
|
| 132 |
+
source=n.get("source", ""),
|
| 133 |
+
published=n.get("published", ""),
|
| 134 |
+
summary=n.get("summary", ""),
|
| 135 |
+
url=n.get("url", ""),
|
| 136 |
+
notable=n.get("notable", True),
|
| 137 |
+
))
|
| 138 |
+
|
| 139 |
return ThreatBrief(
|
| 140 |
region=data.get("region", ""),
|
| 141 |
country=data.get("country", ""),
|
|
|
|
| 151 |
indicators_of_escalation=data.get("indicators_of_escalation", []),
|
| 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)
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
# ---------------------------------------------------------------------------
|
| 170 |
+
# HTML Renderer
|
| 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")
|
| 176 |
|
| 177 |
def badge(label: str, color: str) -> str:
|
| 178 |
return (
|
| 179 |
+
f'<span style="background:{color};color:white;padding:3px 12px;'
|
| 180 |
f'border-radius:12px;font-weight:bold;font-size:0.85em">{label}</span>'
|
| 181 |
)
|
| 182 |
|
| 183 |
def bullet_list(items: list) -> str:
|
| 184 |
if not items:
|
| 185 |
return "<li><em>None identified</em></li>"
|
| 186 |
+
return "".join(f"<li>{i}</li>" for i in items if i and i != "N/A")
|
| 187 |
|
| 188 |
fatalities_str = (
|
| 189 |
str(brief.fatalities_reported)
|
| 190 |
if brief.fatalities_reported is not None
|
| 191 |
else "Unknown"
|
| 192 |
)
|
|
|
|
| 193 |
sources_str = ", ".join(brief.source_types_used) if brief.source_types_used else "N/A"
|
| 194 |
|
| 195 |
+
# Build notable news section
|
| 196 |
+
news_html = ""
|
| 197 |
+
if brief.notable_news:
|
| 198 |
+
cards = []
|
| 199 |
+
for item in brief.notable_news:
|
| 200 |
+
source_badge = (
|
| 201 |
+
f'<span style="background:#EBF5FB;color:#2E86C1;padding:2px 8px;'
|
| 202 |
+
f'border-radius:8px;font-size:0.78em;font-weight:600">{item.source}</span>'
|
| 203 |
+
)
|
| 204 |
+
date_str = (
|
| 205 |
+
f'<span style="color:#888;font-size:0.8em;margin-left:8px">{item.published[:25] if item.published else ""}</span>'
|
| 206 |
+
)
|
| 207 |
+
link_html = (
|
| 208 |
+
f'<a href="{item.url}" target="_blank" style="color:#2E86C1;font-size:0.82em;'
|
| 209 |
+
f'text-decoration:none">Read full article β</a>'
|
| 210 |
+
if item.url else ""
|
| 211 |
+
)
|
| 212 |
+
cards.append(f"""
|
| 213 |
+
<div style="border:1px solid #D5E8F5;border-radius:6px;padding:12px 16px;
|
| 214 |
+
margin-bottom:10px;background:#FAFCFF">
|
| 215 |
+
<div style="margin-bottom:6px">{source_badge}{date_str}</div>
|
| 216 |
+
<div style="font-weight:600;color:#1a1a2e;margin-bottom:5px;line-height:1.4">
|
| 217 |
+
{item.title}
|
| 218 |
+
</div>
|
| 219 |
+
<div style="color:#444;font-size:0.9em;line-height:1.6;margin-bottom:6px">
|
| 220 |
+
{item.summary}
|
| 221 |
+
</div>
|
| 222 |
+
{link_html}
|
| 223 |
+
</div>
|
| 224 |
+
""")
|
| 225 |
+
news_html = f"""
|
| 226 |
+
<div style="background:white;padding:20px 24px;border:1px solid #dee2e6;border-top:none">
|
| 227 |
+
<h3 style="color:#1a1a2e;margin-top:0;margin-bottom:14px">
|
| 228 |
+
π° Notable News
|
| 229 |
+
<span style="font-size:0.7em;font-weight:normal;color:#666;margin-left:8px">
|
| 230 |
+
{len(brief.notable_news)} articles
|
| 231 |
+
</span>
|
| 232 |
+
</h3>
|
| 233 |
+
{"".join(cards)}
|
| 234 |
+
</div>
|
| 235 |
+
"""
|
| 236 |
+
else:
|
| 237 |
+
news_html = """
|
| 238 |
+
<div style="background:#f8f9fa;padding:14px 24px;border:1px solid #dee2e6;border-top:none">
|
| 239 |
+
<p style="margin:0;color:#888;font-style:italic">No notable news articles retrieved.</p>
|
| 240 |
+
</div>
|
| 241 |
+
"""
|
| 242 |
+
|
| 243 |
html = f"""
|
| 244 |
+
<div style="font-family:system-ui,sans-serif;max-width:920px;margin:auto">
|
| 245 |
|
| 246 |
+
<!-- Header -->
|
| 247 |
<div style="background:#1a1a2e;color:white;padding:16px 24px;border-radius:8px 8px 0 0">
|
| 248 |
<h2 style="margin:0 0 4px 0">π‘οΈ OSINT Threat Brief</h2>
|
| 249 |
<p style="margin:0;opacity:0.7;font-size:0.9em">
|
| 250 |
+
{brief.country or 'Unknown'} |
|
| 251 |
{brief.region} |
|
| 252 |
{brief.assessment_date}
|
| 253 |
</p>
|
| 254 |
</div>
|
| 255 |
|
| 256 |
+
<!-- Status bar -->
|
| 257 |
+
<div style="background:#f0f3f8;padding:14px 24px;border:1px solid #dee2e6;
|
| 258 |
+
display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
| 259 |
+
<span><strong>Severity:</strong> {badge(brief.severity, sev_color)}</span>
|
| 260 |
+
<span><strong>Confidence:</strong> {badge(brief.confidence, conf_color)}</span>
|
| 261 |
+
<span style="color:#666;font-size:0.9em"><strong>Sources:</strong> {sources_str}</span>
|
| 262 |
+
<span style="color:#666;font-size:0.9em"><strong>Fatalities:</strong> {fatalities_str}</span>
|
|
|
|
|
|
|
|
|
|
| 263 |
</div>
|
| 264 |
|
| 265 |
+
<!-- Narrative -->
|
| 266 |
<div style="background:white;padding:20px 24px;border:1px solid #dee2e6;border-top:none">
|
| 267 |
<h3 style="color:#1a1a2e;margin-top:0">Analytical Summary</h3>
|
| 268 |
+
<p style="line-height:1.8;color:#333">{brief.narrative_summary or '<em>Not available</em>'}</p>
|
| 269 |
</div>
|
| 270 |
|
| 271 |
+
<!-- Key findings + escalation -->
|
| 272 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;border:1px solid #dee2e6;border-top:none">
|
| 273 |
<div style="padding:16px 20px;border-right:1px solid #dee2e6">
|
| 274 |
<h4 style="color:#1a1a2e;margin-top:0">π Key Findings</h4>
|
| 275 |
+
<ul style="padding-left:18px;line-height:1.9;margin:0">
|
| 276 |
{bullet_list(brief.key_findings)}
|
| 277 |
</ul>
|
| 278 |
</div>
|
|
|
|
| 279 |
<div style="padding:16px 20px">
|
| 280 |
<h4 style="color:#1a1a2e;margin-top:0">β οΈ Escalation Indicators</h4>
|
| 281 |
+
<ul style="padding-left:18px;line-height:1.9;margin:0">
|
| 282 |
{bullet_list(brief.indicators_of_escalation)}
|
| 283 |
</ul>
|
| 284 |
</div>
|
| 285 |
+
</div>
|
| 286 |
|
| 287 |
+
<!-- Actors + locations -->
|
| 288 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;border:1px solid #dee2e6;border-top:none">
|
| 289 |
+
<div style="padding:16px 20px;border-right:1px solid #dee2e6">
|
| 290 |
<h4 style="color:#1a1a2e;margin-top:0">π Primary Actors</h4>
|
| 291 |
+
<ul style="padding-left:18px;line-height:1.9;margin:0">
|
| 292 |
{bullet_list(brief.primary_actors)}
|
| 293 |
</ul>
|
| 294 |
</div>
|
| 295 |
+
<div style="padding:16px 20px">
|
|
|
|
| 296 |
<h4 style="color:#1a1a2e;margin-top:0">π Key Locations</h4>
|
| 297 |
+
<ul style="padding-left:18px;line-height:1.9;margin:0">
|
| 298 |
{bullet_list(brief.key_locations)}
|
| 299 |
</ul>
|
| 300 |
</div>
|
|
|
|
| 301 |
</div>
|
| 302 |
|
| 303 |
+
<!-- Notable news -->
|
| 304 |
+
{news_html}
|
| 305 |
+
|
| 306 |
+
<!-- Watch items -->
|
| 307 |
<div style="background:#fff8e1;padding:16px 24px;border:1px solid #dee2e6;border-top:none">
|
| 308 |
<h4 style="color:#1a1a2e;margin-top:0">π‘ Recommended Watch Items</h4>
|
| 309 |
+
<ul style="padding-left:18px;line-height:1.9;margin:0">
|
| 310 |
{bullet_list(brief.recommended_watch_items)}
|
| 311 |
</ul>
|
| 312 |
</div>
|
| 313 |
|
| 314 |
+
<!-- Footer -->
|
| 315 |
+
<div style="background:#f8f9fa;padding:10px 24px;border:1px solid #dee2e6;border-top:none;
|
| 316 |
+
border-radius:0 0 8px 8px;font-size:0.78em;color:#6c757d">
|
| 317 |
Event types: {', '.join(brief.event_types) or 'N/A'} |
|
| 318 |
+
<em>AI-generated from open sources. Verify before operational use.</em>
|
| 319 |
</div>
|
| 320 |
|
| 321 |
</div>
|