OSINTAgentTool / export.py
Firemedic15's picture
Upload 7 files
3ba4500 verified
Raw
History Blame Contribute Delete
18.4 kB
"""
export.py - Traveler-friendly PDF brief.
Compact 2-page layout: at-a-glance safety info, key risks, news, contacts.
"""
import os
import tempfile
from datetime import datetime
from typing import Optional, Tuple
from fpdf import FPDF
from brief import ThreatBrief
from map_utils import generate_country_map
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
_BLACK = (20, 20, 20)
_DARK = (10, 10, 10)
_MID = (55, 55, 55)
_MUTED = (110, 110, 110)
_LIGHT_GREY = (246, 246, 246)
_RULE = (210, 210, 210)
_LINK = (30, 80, 200)
_WHITE = (255, 255, 255)
_SEV = {
"Critical": (180, 20, 20),
"High": (210, 70, 10),
"Medium": (190, 100, 0),
"Low": (20, 140, 60),
"Minimal": (100, 110, 120),
}
_CON = {
"High": (20, 140, 60),
"Medium": (190, 100, 0),
"Low": (180, 20, 20),
}
_ADV = {
"1": ((20, 140, 60), "Level 1 - Normal Precautions"),
"2": ((190, 100, 0), "Level 2 - Increased Caution"),
"3": ((210, 70, 10), "Level 3 - Reconsider Travel"),
"4": ((180, 20, 20), "Level 4 - Do Not Travel"),
}
_AIR = {
"Open": (20, 140, 60),
"Restricted": (210, 70, 10),
"Partially Restricted": (190, 100, 0),
"Closed": (180, 20, 20),
"Unknown": (110, 110, 110),
}
# ---------------------------------------------------------------------------
# Unicode safety
# ---------------------------------------------------------------------------
_SUBS = str.maketrans({
"—": "--", "–": "-", "‘": "'", "’": "'",
"“": '"', "”": '"', "…": "...", "·": "*",
"•": "-", " ": " ", "→": "->", "←": "<-",
"\xe9": "e", "\xe8": "e", "\xea": "e", "\xe0": "a", "\xe2": "a",
"\xf4": "o", "\xfb": "u", "\xfc": "u", "\xe4": "a", "\xf6": "o",
"\xdf": "ss", "\xe7": "c", "\xf1": "n", "\xed": "i", "\xf3": "o",
"\xfa": "u", "\xe1": "a", "\xe6": "ae",
})
def _s(text) -> str:
if not isinstance(text, str):
text = str(text)
return text.translate(_SUBS).encode("latin-1", errors="replace").decode("latin-1")
# ---------------------------------------------------------------------------
# PDF class
# ---------------------------------------------------------------------------
class _PDF(FPDF):
def __init__(self, country="", date=""):
super().__init__()
self._country = country
self._date = date
def header(self):
self.set_fill_color(255, 255, 255)
self.rect(0, 0, self.w, self.h, "F")
self.set_fill_color(*_LIGHT_GREY)
self.rect(0, 0, self.w, 6, "F")
self.set_font("Helvetica", "", 5.5)
self.set_text_color(*_MUTED)
self.set_xy(0, 0.8)
self.cell(self.w, 4.5,
"UNCLASSIFIED // FOR OFFICIAL USE ONLY // AI-GENERATED FROM OPEN SOURCES",
align="C")
self.ln(6)
def footer(self):
self.set_y(-14)
self.set_fill_color(*_LIGHT_GREY)
self.rect(0, self.h - 14, self.w, 14, "F")
# Disclaimer
self.set_font("Helvetica", "I", 5.5)
self.set_text_color(*_MUTED)
self.set_x(self.l_margin)
self.cell(self.epw, 4,
"AI-generated from open sources. Not for operational use without independent verification. "
"Verify all embassy/consular contacts before travel.",
align="L")
self.ln(4)
# Page line
self.set_font("Helvetica", "", 6.5)
self.set_x(self.l_margin)
self.cell(self.epw / 2, 5,
_s(f"{self._country} | {self._date} | OSINT Intelligence Platform"),
align="L")
self.cell(self.epw / 2, 5,
f"Page {self.page_no()}", align="R")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _rx(pdf): pdf.set_x(pdf.l_margin)
def _lm(pdf): return pdf.l_margin
def _epw(pdf): return pdf.epw
def _rule(pdf, color=_RULE):
_rx(pdf)
pdf.set_draw_color(*color)
pdf.line(_lm(pdf), pdf.get_y(), _lm(pdf) + _epw(pdf), pdf.get_y())
pdf.ln(2.5)
def _section(pdf, title, accent=(50, 80, 160)):
pdf.ln(3)
_rx(pdf)
y = pdf.get_y()
pdf.set_fill_color(*_LIGHT_GREY)
pdf.rect(_lm(pdf), y, _epw(pdf), 6.5, "F")
pdf.set_fill_color(*accent)
pdf.rect(_lm(pdf), y, 2.5, 6.5, "F")
pdf.set_xy(_lm(pdf) + 5, y + 0.5)
pdf.set_font("Helvetica", "B", 7.5)
pdf.set_text_color(*_DARK)
pdf.cell(_epw(pdf) - 5, 5.5, _s(title.upper()), ln=True)
pdf.ln(2.5)
def _label(pdf, text):
_rx(pdf)
pdf.set_font("Helvetica", "B", 6.5)
pdf.set_text_color(*_MUTED)
pdf.cell(_epw(pdf), 3.5, _s(text.upper()), ln=True)
pdf.ln(0.5)
def _body(pdf, text, size=8.5, color=None):
_rx(pdf)
pdf.set_font("Helvetica", "", size)
pdf.set_text_color(*(color or _BLACK))
pdf.multi_cell(_epw(pdf), 4.5, _s(text))
def _bullets(pdf, items, size=8.5):
if not items:
return
pdf.set_font("Helvetica", "", size)
pdf.set_text_color(*_BLACK)
for item in items:
s = str(item).strip()
if s and s.lower() not in ("n/a", "none", ""):
_rx(pdf)
pdf.multi_cell(_epw(pdf), 4.5, _s(f" • {s}"))
def _badge(pdf, x, y, w, h, label, rgb):
pdf.set_fill_color(*rgb)
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", "B", 7.5)
pdf.set_xy(x, y)
pdf.cell(w, h, _s(f" {label}"), fill=True, border=0, ln=0)
def _kv(pdf, label, value, label_w=28):
if not value:
return
_rx(pdf)
pdf.set_font("Helvetica", "B", 8)
pdf.set_text_color(*_MID)
pdf.cell(label_w, 4.5, _s(f"{label}:"), ln=False)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_BLACK)
pdf.multi_cell(_epw(pdf) - label_w, 4.5, _s(value))
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def generate_pdf(brief: ThreatBrief) -> str:
date_str = brief.assessment_date or datetime.utcnow().strftime("%Y-%m-%d")
pdf = _PDF(country=brief.country or "", date=date_str)
pdf.set_auto_page_break(auto=True, margin=12)
pdf.add_page()
lm = pdf.l_margin
epw = pdf.epw
# =========================================================
# TITLE BLOCK
# =========================================================
pdf.set_font("Helvetica", "B", 20)
pdf.set_text_color(*_DARK)
_rx(pdf)
pdf.cell(epw, 9, _s(brief.country or "Unknown Country"), ln=True)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*_MUTED)
_rx(pdf)
pdf.cell(epw, 4, _s(
f"{brief.region or 'Unknown Region'} | "
f"Assessment: {date_str} | "
f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}"
), ln=True)
pdf.ln(3)
# 4 status badges
level = (brief.travel_advisory_level or "").strip()
adv_rgb, adv_label = _ADV.get(level, ((110,110,110), f"Level {level}" if level else "Advisory N/A"))
sev_rgb = _SEV.get(brief.severity, (110,110,110))
as_rgb = _AIR.get(brief.airspace_status, (110,110,110))
gap = 2
badge_w = (epw - gap * 3) / 4
by = pdf.get_y()
pdf.set_font("Helvetica", "B", 7.5)
_badge(pdf, lm, by, badge_w, 7, f"Severity: {brief.severity or 'N/A'}", sev_rgb)
_badge(pdf, lm + (badge_w+gap), by, badge_w, 7, f"Confidence: {brief.confidence or 'N/A'}", _CON.get(brief.confidence, (110,110,110)))
_badge(pdf, lm + (badge_w+gap)*2, by, badge_w, 7, f"Advisory: L{level or '?'}", adv_rgb)
_badge(pdf, lm + (badge_w+gap)*3, by, badge_w, 7, f"Airspace: {brief.airspace_status or 'N/A'}", as_rgb)
pdf.ln(9)
pdf.ln(3)
_rule(pdf)
# =========================================================
# MAP + ADVISORY SUMMARY (side by side)
# =========================================================
map_col_w = epw * 0.52
info_col_w = epw - map_col_w - 4
row_start = pdf.get_y()
# --- Map (left) ---
map_path = map_err = None
try:
result = generate_country_map(brief.country or "")
map_path, map_err = result if isinstance(result, tuple) else (result, None)
except Exception as e:
map_err = str(e)
map_h = 0
if map_path:
try:
pdf.image(map_path, x=lm, y=row_start, w=map_col_w)
map_h = map_col_w * (540 / 900) # native aspect ratio
except Exception as e:
map_err = str(e)
finally:
try: os.unlink(map_path)
except: pass
if not map_path or map_err:
pdf.set_xy(lm, row_start)
pdf.set_fill_color(*_LIGHT_GREY)
pdf.rect(lm, row_start, map_col_w, 38, "F")
pdf.set_xy(lm, row_start + 15)
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_MUTED)
pdf.cell(map_col_w, 5, _s(f"Map unavailable"), align="C", ln=True)
map_h = 38
# --- Advisory info (right) ---
info_x = lm + map_col_w + 4
pdf.set_xy(info_x, row_start)
# Advisory banner
pdf.set_fill_color(*adv_rgb)
pdf.set_text_color(255,255,255)
pdf.set_font("Helvetica", "B", 8)
pdf.cell(info_col_w, 7, _s(f" {adv_label}"), fill=True, ln=True)
pdf.ln(2)
if brief.travel_advisory_indicators:
pdf.set_x(info_x)
pdf.set_font("Helvetica", "B", 7)
pdf.set_text_color(*_MUTED)
pdf.cell(info_col_w, 3.5, "RISK CATEGORIES", ln=True)
pdf.set_x(info_x)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_BLACK)
pdf.multi_cell(info_col_w, 4.5, _s(", ".join(brief.travel_advisory_indicators)))
pdf.ln(2)
if brief.narrative_summary:
pdf.set_x(info_x)
pdf.set_font("Helvetica", "B", 7)
pdf.set_text_color(*_MUTED)
pdf.cell(info_col_w, 3.5, "SITUATION", ln=True)
pdf.set_x(info_x)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_BLACK)
# Limit to ~3 lines in this column
summary = brief.narrative_summary[:260]
if len(brief.narrative_summary) > 260:
summary = summary.rsplit(" ", 1)[0] + "..."
pdf.multi_cell(info_col_w, 4.5, _s(summary))
# Advance Y past the taller of the two columns
pdf.set_y(row_start + max(map_h, pdf.get_y() - row_start) + 3)
_rule(pdf)
# =========================================================
# KEY RISKS (2 columns: findings | watch items)
# =========================================================
_section(pdf, "Key Risks & Watch Items", accent=(180, 20, 20))
col_w = epw / 2 - 2
row_y = pdf.get_y()
row_pg = pdf.page
# Left: risk analysis snippet + key findings
_rx(pdf)
if brief.risk_analysis:
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*_BLACK)
ra = brief.risk_analysis[:300]
if len(brief.risk_analysis) > 300:
ra = ra.rsplit(" ", 1)[0] + "..."
pdf.multi_cell(col_w, 4.5, _s(ra))
pdf.ln(1.5)
all_findings = (brief.key_findings or []) + (brief.indicators_of_escalation or [])
if all_findings:
_rx(pdf)
pdf.set_font("Helvetica", "B", 6.5)
pdf.set_text_color(*_MUTED)
pdf.cell(col_w, 3.5, "KEY FINDINGS", ln=True)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*_BLACK)
for item in all_findings[:5]:
s = str(item).strip()
if s and s.lower() not in ("n/a","none",""):
_rx(pdf)
pdf.multi_cell(col_w, 4.5, _s(f" - {s}"))
left_end_y = pdf.get_y()
left_end_pg = pdf.page
# Right: actors + watch items
if left_end_pg == row_pg:
rx = lm + col_w + 4
pdf.set_xy(rx, row_y)
all_actors = (brief.primary_actors or [])[:4]
if all_actors:
pdf.set_font("Helvetica", "B", 6.5)
pdf.set_text_color(*_MUTED)
pdf.cell(col_w, 3.5, "PRIMARY ACTORS", ln=True)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*_BLACK)
for a in all_actors:
pdf.set_x(rx)
pdf.multi_cell(col_w, 4.5, _s(f" - {a}"))
pdf.set_x(rx); pdf.ln(2)
watch = (brief.recommended_watch_items or [])[:4]
if watch:
pdf.set_x(rx)
pdf.set_font("Helvetica", "B", 6.5)
pdf.set_text_color(*_MUTED)
pdf.cell(col_w, 3.5, "MONITOR", ln=True)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*_BLACK)
for w in watch:
pdf.set_x(rx)
pdf.multi_cell(col_w, 4.5, _s(f" - {w}"))
pdf.set_y(max(left_end_y, pdf.get_y()) + 2)
else:
pdf.set_y(left_end_y + 2)
_rule(pdf)
# =========================================================
# AIRSPACE + TRAVEL CONTACTS (side by side)
# =========================================================
_section(pdf, "Airspace & Travel", accent=(210, 70, 10))
col_w = epw / 2 - 2
row_y = pdf.get_y()
row_pg = pdf.page
# Left: airspace
_rx(pdf)
pdf.set_fill_color(*as_rgb)
pdf.set_text_color(255,255,255)
pdf.set_font("Helvetica", "B", 8)
pdf.cell(col_w, 6.5, _s(f" {brief.airspace_status or 'Unknown'}"), fill=True, ln=True)
pdf.ln(1.5)
if brief.aviation_notes:
_rx(pdf)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_MID)
note = brief.aviation_notes[:200]
pdf.multi_cell(col_w, 4.5, _s(note))
pdf.ln(1)
combined_air = (brief.no_fly_zones or []) + (brief.airspace_restrictions or [])
if combined_air:
_rx(pdf)
pdf.set_font("Helvetica", "B", 6.5)
pdf.set_text_color(*_MUTED)
pdf.cell(col_w, 3.5, "RESTRICTIONS / NO-FLY ZONES", ln=True)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_BLACK)
for item in combined_air[:4]:
if str(item).strip().lower() not in ("none identified", "n/a", "none", ""):
_rx(pdf)
pdf.multi_cell(col_w, 4.5, _s(f" - {item}"))
air_end_y = pdf.get_y()
air_end_pg = pdf.page
# Right: travel advisory + embassy
if air_end_pg == row_pg:
rx = lm + col_w + 4
pdf.set_xy(rx, row_y)
pdf.set_fill_color(*adv_rgb)
pdf.set_text_color(255,255,255)
pdf.set_font("Helvetica", "B", 8)
pdf.cell(col_w, 6.5, _s(f" {adv_label}"), fill=True, ln=True)
pdf.ln(1.5)
if brief.travel_advisory_date:
pdf.set_x(rx)
pdf.set_font("Helvetica", "I", 7.5)
pdf.set_text_color(*_MUTED)
pdf.cell(col_w, 4, _s(f"Updated: {brief.travel_advisory_date}"), ln=True)
if brief.passport_country and brief.embassy_name:
pdf.set_x(rx); pdf.ln(1.5)
pdf.set_font("Helvetica", "B", 7.5)
pdf.set_text_color(*_DARK)
pdf.set_x(rx)
pdf.cell(col_w, 4.5, _s(f"{brief.passport_country} Embassy"), ln=True)
pdf.set_x(rx)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_BLACK)
pdf.multi_cell(col_w, 4.5, _s(brief.embassy_name))
if brief.embassy_address:
pdf.set_x(rx)
pdf.multi_cell(col_w, 4.5, _s(brief.embassy_address))
if brief.embassy_emergency_phone:
pdf.set_x(rx)
pdf.set_font("Helvetica", "B", 8)
pdf.set_text_color((180,20,20))
pdf.cell(col_w, 4.5, _s(f"Emergency: {brief.embassy_emergency_phone}"), ln=True)
if brief.embassy_phone:
pdf.set_x(rx)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_BLACK)
pdf.cell(col_w, 4.5, _s(f"Main: {brief.embassy_phone}"), ln=True)
if brief.embassy_website:
pdf.set_x(rx)
pdf.set_font("Helvetica", "I", 7.5)
pdf.set_text_color(*_LINK)
pdf.cell(col_w, 4.5, _s(brief.embassy_website), ln=True)
pdf.set_y(max(air_end_y, pdf.get_y()) + 2)
else:
pdf.set_y(air_end_y + 2)
_rule(pdf)
# =========================================================
# NEWS HEADLINES (compact — title + source + date only)
# =========================================================
news = brief.notable_news or []
if news:
_section(pdf, f"Latest News ({len(news)} articles)", accent=(37, 99, 200))
for article in news:
# Title bold
_rx(pdf)
pdf.set_font("Helvetica", "B", 8.5)
pdf.set_text_color(*_BLACK)
pdf.multi_cell(epw, 4.5, _s(article.title or "Untitled"))
# Source | date | [NOTABLE] on same line
_rx(pdf)
pdf.set_font("Helvetica", "I", 7)
pdf.set_text_color(*_MUTED)
date_s = (article.published or "")[:16]
notable = " [NOTABLE]" if article.notable else ""
pdf.cell(epw, 3.5, _s(f"{article.source} | {date_s}{notable}"), ln=True)
# One-line summary (capped at 140 chars)
if article.summary:
_rx(pdf)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_MID)
summ = article.summary[:140]
if len(article.summary) > 140:
summ = summ.rsplit(" ", 1)[0] + "..."
pdf.multi_cell(epw, 4, _s(summ))
# URL
if article.url:
_rx(pdf)
pdf.set_font("Helvetica", "I", 7)
pdf.set_text_color(*_LINK)
url_d = article.url[:90] + "..." if len(article.url) > 90 else article.url
pdf.cell(epw, 3.5, _s(url_d), ln=True)
pdf.ln(1.5)
_rx(pdf)
pdf.set_draw_color(*_RULE)
pdf.line(lm, pdf.get_y(), lm + epw, pdf.get_y())
pdf.ln(2)
path = tempfile.mktemp(suffix=".pdf")
pdf.output(path)
return path