356 lines
14 KiB
Python
356 lines
14 KiB
Python
"""Generador de PDF para location_agent.
|
|
|
|
Usa reportlab para generar PDF profesional.
|
|
Guarda en: analyses/location_reports/<fecha>_<dirección>.pdf
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
PDF_DIR = _PROJECT_ROOT / "analyses" / "location_reports"
|
|
|
|
|
|
def export_pdf(report: dict) -> Path:
|
|
"""Genera el PDF del reporte. Devuelve la ruta del archivo."""
|
|
try:
|
|
from reportlab.lib.pagesizes import letter
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import inch
|
|
from reportlab.lib import colors
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
|
HRFlowable, PageBreak,
|
|
)
|
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
|
except ImportError:
|
|
raise ImportError("Instalar reportlab: pip install reportlab")
|
|
|
|
PDF_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Nombre del archivo
|
|
safe_addr = re.sub(r"[^\w\s-]", "", report.get("address", "report"))[:60]
|
|
safe_addr = re.sub(r"\s+", "_", safe_addr.strip())
|
|
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = PDF_DIR / f"{date_str}_{safe_addr}.pdf"
|
|
|
|
doc = SimpleDocTemplate(
|
|
str(filename),
|
|
pagesize=letter,
|
|
rightMargin=0.75 * inch,
|
|
leftMargin=0.75 * inch,
|
|
topMargin=0.75 * inch,
|
|
bottomMargin=0.75 * inch,
|
|
)
|
|
|
|
styles = getSampleStyleSheet()
|
|
|
|
# Estilos personalizados
|
|
title_style = ParagraphStyle("ARTitle", parent=styles["Title"],
|
|
fontSize=20, textColor=colors.HexColor("#1a2744"),
|
|
spaceAfter=6)
|
|
h1_style = ParagraphStyle("ARH1", parent=styles["Heading1"],
|
|
fontSize=14, textColor=colors.HexColor("#1a2744"),
|
|
spaceBefore=12, spaceAfter=6)
|
|
h2_style = ParagraphStyle("ARH2", parent=styles["Heading2"],
|
|
fontSize=11, textColor=colors.HexColor("#2d5a8e"))
|
|
body_style = ParagraphStyle("ARBody", parent=styles["Normal"],
|
|
fontSize=9, leading=13)
|
|
score_style = ParagraphStyle("ARScore", parent=styles["Normal"],
|
|
fontSize=28, textColor=colors.HexColor("#1a2744"),
|
|
alignment=TA_CENTER, fontName="Helvetica-Bold")
|
|
caption_style = ParagraphStyle("ARCaption", parent=styles["Normal"],
|
|
fontSize=8, textColor=colors.grey)
|
|
|
|
story = []
|
|
|
|
# --- PORTADA ---
|
|
story.append(Spacer(1, 0.5 * inch))
|
|
story.append(Paragraph("AR-HOUSE", title_style))
|
|
story.append(Paragraph("Location Intelligence Report", styles["Heading2"]))
|
|
story.append(HRFlowable(width="100%", thickness=2, color=colors.HexColor("#1a2744")))
|
|
story.append(Spacer(1, 0.2 * inch))
|
|
story.append(Paragraph(report.get("address", ""), h1_style))
|
|
story.append(Paragraph(f"Análisis: {report.get('analysis_date', '')}", caption_style))
|
|
story.append(Spacer(1, 0.3 * inch))
|
|
|
|
# Score general grande
|
|
overall = report.get("overall_score", 0)
|
|
score_color = _score_color(overall)
|
|
story.append(Paragraph(
|
|
f'<font color="{score_color}">{overall}/100</font>',
|
|
score_style,
|
|
))
|
|
story.append(Paragraph("Score General de Ubicación", styles["Normal"]))
|
|
story.append(Spacer(1, 0.3 * inch))
|
|
|
|
# Tabla de scores parciales
|
|
scores = report.get("scores", {})
|
|
score_labels = {
|
|
"crime": "Criminalidad",
|
|
"property": "Mercado Inmobiliario",
|
|
"schools": "Escuelas",
|
|
"amenities": "Amenities",
|
|
"demographics": "Demografía",
|
|
"maritime": "Mercado Marítimo",
|
|
"lifestyle": "Lifestyle Náutico",
|
|
}
|
|
tdata = [["Dimensión", "Score", "Peso"]]
|
|
for k, label in score_labels.items():
|
|
s = scores.get(k, 0)
|
|
w = int({"crime": 20, "property": 20, "schools": 10, "amenities": 15,
|
|
"demographics": 10, "maritime": 15, "lifestyle": 10}.get(k, 0))
|
|
tdata.append([label, f"{s}/100", f"{w}%"])
|
|
|
|
t = Table(tdata, colWidths=[3 * inch, 1.2 * inch, 0.8 * inch])
|
|
t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a2744")),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, 0), (-1, -1), 9),
|
|
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f7fa")]),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#d0d0d0")),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
story.append(t)
|
|
story.append(PageBreak())
|
|
|
|
# --- RESUMEN EJECUTIVO ---
|
|
exec_sec = report.get("sections", {}).get("executive", {})
|
|
story.append(Paragraph("1. Resumen Ejecutivo", h1_style))
|
|
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#d0d0d0")))
|
|
|
|
if exec_sec.get("summary"):
|
|
story.append(Paragraph(exec_sec["summary"], body_style))
|
|
story.append(Spacer(1, 0.15 * inch))
|
|
|
|
if exec_sec.get("strengths"):
|
|
story.append(Paragraph("✅ Principales Fortalezas:", h2_style))
|
|
for s in exec_sec["strengths"]:
|
|
story.append(Paragraph(f"• {s}", body_style))
|
|
|
|
if exec_sec.get("weaknesses"):
|
|
story.append(Spacer(1, 0.1 * inch))
|
|
story.append(Paragraph("⚠️ Principales Riesgos:", h2_style))
|
|
for w in exec_sec["weaknesses"]:
|
|
story.append(Paragraph(f"• {w}", body_style))
|
|
story.append(PageBreak())
|
|
|
|
# --- SECCIONES ---
|
|
section_order = ["crime", "property", "schools", "amenities",
|
|
"demographics", "maritime", "lifestyle"]
|
|
section_nums = {k: i+2 for i, k in enumerate(section_order)}
|
|
|
|
for key in section_order:
|
|
sec = report.get("sections", {}).get(key, {})
|
|
num = section_nums[key]
|
|
score_val = sec.get("score", 0)
|
|
title = sec.get("title", key.title())
|
|
|
|
story.append(Paragraph(f"{num}. {title}", h1_style))
|
|
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#d0d0d0")))
|
|
|
|
sc = sec.get("score")
|
|
if sc is not None:
|
|
clr = _score_color(sc)
|
|
story.append(Paragraph(
|
|
f'Score: <font color="{clr}"><b>{sc}/100</b></font>', body_style
|
|
))
|
|
|
|
# Datos clave por sección
|
|
_add_section_data(story, key, sec, body_style, h2_style)
|
|
|
|
# Narrativa
|
|
if sec.get("narrative"):
|
|
story.append(Spacer(1, 0.1 * inch))
|
|
story.append(Paragraph("Análisis:", h2_style))
|
|
story.append(Paragraph(sec["narrative"], body_style))
|
|
|
|
# Fuentes y errores
|
|
if sec.get("sources"):
|
|
story.append(Spacer(1, 0.05 * inch))
|
|
story.append(Paragraph(
|
|
f"<i>Fuentes: {', '.join(sec['sources'])}</i>", caption_style
|
|
))
|
|
if sec.get("errors"):
|
|
for err in sec["errors"]:
|
|
story.append(Paragraph(f"<i>⚠ {err}</i>", caption_style))
|
|
|
|
story.append(PageBreak())
|
|
|
|
# --- PIE DE PÁGINA ---
|
|
story.append(Paragraph("Datos Técnicos", h1_style))
|
|
story.append(Paragraph(
|
|
f"Generado por AR-House Location Intelligence Agent | {report.get('analysis_date', '')} | "
|
|
f"Coordenadas: {report.get('lat', '')}, {report.get('lon', '')}",
|
|
caption_style,
|
|
))
|
|
|
|
doc.build(story)
|
|
return filename
|
|
|
|
|
|
def _score_color(score: int) -> str:
|
|
if score >= 75:
|
|
return "#1a7a1a" # verde
|
|
elif score >= 50:
|
|
return "#e6a817" # amarillo
|
|
else:
|
|
return "#b83232" # rojo
|
|
|
|
|
|
def _add_section_data(story, key: str, sec: dict, body_style, h2_style) -> None:
|
|
"""Agrega datos específicos de cada sección al story del PDF."""
|
|
from reportlab.platypus import Paragraph, Spacer, Table, TableStyle
|
|
from reportlab.lib import colors
|
|
|
|
story.append(Spacer(1, 0.1 * inch if True else 0))
|
|
|
|
if key == "crime":
|
|
total = sec.get("total_crimes_30d", "N/A")
|
|
violent = "Sí" if sec.get("has_violent") else "No"
|
|
story.append(Paragraph(f"Crímenes últimos 30 días: <b>{total}</b>", body_style))
|
|
story.append(Paragraph(f"Presencia de crimen violento: <b>{violent}</b>", body_style))
|
|
types = sec.get("crime_types", {})
|
|
if types:
|
|
story.append(Paragraph("Tipos de crimen:", h2_style))
|
|
for t, count in list(types.items())[:8]:
|
|
story.append(Paragraph(f"• {t}: {count}", body_style))
|
|
|
|
elif key == "property":
|
|
fields = [
|
|
("Precio mediano listado", "median_list_price", "$"),
|
|
("Precio por sqft", "price_per_sqft", "$/sqft"),
|
|
("Apreciación 1 año", "appreciation_1y", "%"),
|
|
("Días en mercado (prom.)", "days_on_market", " días"),
|
|
("Inventario activo", "inventory", " propiedades"),
|
|
("Valor tasado condado", "county_assessed_value", "$"),
|
|
]
|
|
for label, field, unit in fields:
|
|
val = sec.get(field)
|
|
if val is not None:
|
|
if unit == "$":
|
|
display = f"${val:,}"
|
|
elif unit == "$/sqft":
|
|
display = f"${val:,}/sqft"
|
|
else:
|
|
display = f"{val}{unit}"
|
|
story.append(Paragraph(f"{label}: <b>{display}</b>", body_style))
|
|
|
|
elif key == "schools":
|
|
avg = sec.get("avg_rating")
|
|
if avg is not None:
|
|
story.append(Paragraph(f"Rating promedio de escuelas: <b>{avg}/10</b>", body_style))
|
|
for level, label in [("best_elementary", "Mejor primaria"),
|
|
("best_middle", "Mejor middle school"),
|
|
("best_high", "Mejor high school")]:
|
|
school = sec.get(level)
|
|
if school:
|
|
story.append(Paragraph(
|
|
f"{label}: <b>{school.get('name', 'N/A')}</b> (rating: {school.get('rating', 'N/A')}/10)",
|
|
body_style,
|
|
))
|
|
schools = sec.get("schools", [])[:8]
|
|
if schools:
|
|
tdata = [["Escuela", "Nivel", "Rating"]]
|
|
for s in schools:
|
|
tdata.append([s.get("name", "")[:35], s.get("level", ""), str(s.get("rating", "N/A"))])
|
|
t = Table(tdata, colWidths=[3.2 * inch, 1.2 * inch, 0.8 * inch])
|
|
t.setStyle(TableStyle([
|
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#d0d0d0")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f7fa")]),
|
|
]))
|
|
story.append(t)
|
|
|
|
elif key == "amenities":
|
|
ws = sec.get("walk_score")
|
|
if ws is not None:
|
|
story.append(Paragraph(f"Walk Score estimado: <b>{ws}/100</b>", body_style))
|
|
nearest = sec.get("nearest", {})
|
|
cat_labels = {
|
|
"supermarket": "Supermercado más cercano",
|
|
"hospital": "Hospital/clínica más cercano",
|
|
"restaurant": "Restaurante más cercano",
|
|
"park": "Parque más cercano",
|
|
"gym": "Gimnasio más cercano",
|
|
}
|
|
for cat, label in cat_labels.items():
|
|
item = nearest.get(cat)
|
|
if item:
|
|
story.append(Paragraph(
|
|
f"{label}: <b>{item.get('name', 'N/A')}</b> ({item.get('dist_miles', '?')} mi)",
|
|
body_style,
|
|
))
|
|
|
|
elif key == "demographics":
|
|
income = sec.get("median_household_income")
|
|
if income:
|
|
story.append(Paragraph(f"Ingreso mediano del hogar: <b>${income:,}/año</b>", body_style))
|
|
age = sec.get("median_age")
|
|
if age:
|
|
story.append(Paragraph(f"Edad mediana: <b>{age} años</b>", body_style))
|
|
unemp = sec.get("unemployment_rate")
|
|
if unemp is not None:
|
|
story.append(Paragraph(f"Tasa de desempleo: <b>{unemp}%</b>", body_style))
|
|
edu = sec.get("education_bachelors_pct")
|
|
if edu is not None:
|
|
story.append(Paragraph(f"Con título universitario: <b>{edu}%</b>", body_style))
|
|
pop = sec.get("total_population")
|
|
if pop:
|
|
story.append(Paragraph(f"Población total del área: <b>{pop:,}</b>", body_style))
|
|
eth = sec.get("ethnicity", {})
|
|
if eth:
|
|
story.append(Paragraph("Distribución étnica:", h2_style))
|
|
for k, v in eth.items():
|
|
label = k.replace("_pct", "").replace("_", " ").title()
|
|
story.append(Paragraph(f"• {label}: {v}%", body_style))
|
|
|
|
elif key == "maritime":
|
|
shipyards = sec.get("shipyards", [])
|
|
marinas = sec.get("marinas_with_jobs", [])
|
|
employers = sec.get("maritime_employers", [])
|
|
story.append(Paragraph(f"Astilleros encontrados: <b>{len(shipyards)}</b>", body_style))
|
|
story.append(Paragraph(f"Marinas encontradas: <b>{len(marinas)}</b>", body_style))
|
|
story.append(Paragraph(f"Empleadores portuarios: <b>{len(employers)}</b>", body_style))
|
|
bls = sec.get("bls_employment", {})
|
|
if bls.get("latest_employment"):
|
|
story.append(Paragraph(
|
|
f"Empleo marítimo en el estado: <b>{bls['latest_employment']}K empleados</b> "
|
|
f"({bls.get('year', '')})",
|
|
body_style,
|
|
))
|
|
|
|
elif key == "lifestyle":
|
|
nm = sec.get("nearest_marina")
|
|
if nm:
|
|
story.append(Paragraph(
|
|
f"Marina más cercana: <b>{nm.get('name', 'N/A')}</b> ({nm.get('dist_miles', '?')} mi)",
|
|
body_style,
|
|
))
|
|
nb = sec.get("nearest_beach")
|
|
if nb:
|
|
story.append(Paragraph(
|
|
f"Playa más cercana: <b>{nb.get('name', 'N/A')}</b> ({nb.get('dist_miles', '?')} mi)",
|
|
body_style,
|
|
))
|
|
story.append(Paragraph(
|
|
f"Acceso oceánico: <b>{'Sí' if sec.get('ocean_access') else 'No'}</b>", body_style
|
|
))
|
|
story.append(Paragraph(
|
|
f"Vías fluviales cercanas: <b>{'Sí' if sec.get('waterway_nearby') else 'No'}</b>",
|
|
body_style,
|
|
))
|
|
story.append(Paragraph(
|
|
f"Boat ramps en 10 millas: <b>{len(sec.get('boat_ramps', []))}</b>", body_style
|
|
))
|
|
|
|
from reportlab.lib.units import inch
|
|
story.append(Spacer(1, 0.1 * inch))
|