"""Generador de PDF para location_agent. Usa reportlab para generar PDF profesional. Guarda en: analyses/location_reports/_.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'{overall}/100', 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: {sc}/100', 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"Fuentes: {', '.join(sec['sources'])}", caption_style )) if sec.get("errors"): for err in sec["errors"]: story.append(Paragraph(f"⚠ {err}", 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: {total}", body_style)) story.append(Paragraph(f"Presencia de crimen violento: {violent}", 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}: {display}", body_style)) elif key == "schools": avg = sec.get("avg_rating") if avg is not None: story.append(Paragraph(f"Rating promedio de escuelas: {avg}/10", 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}: {school.get('name', 'N/A')} (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: {ws}/100", 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}: {item.get('name', 'N/A')} ({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: ${income:,}/año", body_style)) age = sec.get("median_age") if age: story.append(Paragraph(f"Edad mediana: {age} años", body_style)) unemp = sec.get("unemployment_rate") if unemp is not None: story.append(Paragraph(f"Tasa de desempleo: {unemp}%", body_style)) edu = sec.get("education_bachelors_pct") if edu is not None: story.append(Paragraph(f"Con título universitario: {edu}%", body_style)) pop = sec.get("total_population") if pop: story.append(Paragraph(f"Población total del área: {pop:,}", 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: {len(shipyards)}", body_style)) story.append(Paragraph(f"Marinas encontradas: {len(marinas)}", body_style)) story.append(Paragraph(f"Empleadores portuarios: {len(employers)}", body_style)) bls = sec.get("bls_employment", {}) if bls.get("latest_employment"): story.append(Paragraph( f"Empleo marítimo en el estado: {bls['latest_employment']}K empleados " f"({bls.get('year', '')})", body_style, )) elif key == "lifestyle": nm = sec.get("nearest_marina") if nm: story.append(Paragraph( f"Marina más cercana: {nm.get('name', 'N/A')} ({nm.get('dist_miles', '?')} mi)", body_style, )) nb = sec.get("nearest_beach") if nb: story.append(Paragraph( f"Playa más cercana: {nb.get('name', 'N/A')} ({nb.get('dist_miles', '?')} mi)", body_style, )) story.append(Paragraph( f"Acceso oceánico: {'Sí' if sec.get('ocean_access') else 'No'}", body_style )) story.append(Paragraph( f"Vías fluviales cercanas: {'Sí' if sec.get('waterway_nearby') else 'No'}", body_style, )) story.append(Paragraph( f"Boat ramps en 10 millas: {len(sec.get('boat_ramps', []))}", body_style )) from reportlab.lib.units import inch story.append(Spacer(1, 0.1 * inch))