feat: AR-House initial commit

This commit is contained in:
2026-07-03 12:24:58 -04:00
commit 047c05287a
216 changed files with 127552 additions and 0 deletions
+355
View File
@@ -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 = "" 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>{'' if sec.get('ocean_access') else 'No'}</b>", body_style
))
story.append(Paragraph(
f"Vías fluviales cercanas: <b>{'' 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))