feat: AR-House initial commit
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user