389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""
|
|
AR-House — Location Intelligence
|
|
=================================
|
|
Página Streamlit para análisis de ubicación con score 0-100.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import streamlit as st
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_ROOT))
|
|
|
|
from location_agent import run_location_agent
|
|
from location_agent.db import list_reports, get_report
|
|
from location_agent.utils.ollama_client import is_available as ollama_ok
|
|
|
|
# ── Config ────────────────────────────────────────────────────────────────────
|
|
st.set_page_config(
|
|
page_title="AR-House · Location Intelligence",
|
|
page_icon="🏠",
|
|
layout="wide",
|
|
)
|
|
|
|
st.markdown("""
|
|
<style>
|
|
.score-badge {
|
|
font-size: 3rem; font-weight: 800; text-align: center;
|
|
padding: 0.5rem 1.5rem; border-radius: 12px;
|
|
display: inline-block; margin: 0 auto;
|
|
}
|
|
.score-high { background: #d4edda; color: #155724; }
|
|
.score-medium { background: #fff3cd; color: #856404; }
|
|
.score-low { background: #f8d7da; color: #721c24; }
|
|
</style>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
def score_badge(score: int) -> str:
|
|
cls = "score-high" if score >= 75 else ("score-medium" if score >= 50 else "score-low")
|
|
return f'<div class="score-badge {cls}">{score}/100</div>'
|
|
|
|
|
|
def score_color(score: int) -> str:
|
|
return "green" if score >= 75 else ("orange" if score >= 50 else "red")
|
|
|
|
|
|
def render_section_data(key: str, sec: dict) -> None:
|
|
"""Renderiza los datos clave de cada sección."""
|
|
if key == "crime":
|
|
st.metric("Crímenes (30 días)", sec.get("total_crimes_30d", "N/A"))
|
|
st.metric("Crimen violento", "Sí ⚠️" if sec.get("has_violent") else "No ✅")
|
|
types = sec.get("crime_types", {})
|
|
if types:
|
|
st.markdown("**Tipos de crimen:**")
|
|
for t, n in list(types.items())[:6]:
|
|
st.markdown(f"• {t}: {n}")
|
|
|
|
elif key == "property":
|
|
mlp = sec.get("median_list_price")
|
|
ppsf = sec.get("price_per_sqft")
|
|
dom = sec.get("days_on_market")
|
|
inv = sec.get("inventory")
|
|
if mlp: st.metric("Precio mediano listado", f"${mlp:,}")
|
|
if ppsf: st.metric("Precio por sqft", f"${ppsf:,}")
|
|
if dom: st.metric("Días en mercado (prom.)", dom)
|
|
if inv: st.metric("Inventario activo", inv)
|
|
cav = sec.get("county_assessed_value")
|
|
if cav: st.metric("Valor tasado condado", f"${cav:,}")
|
|
|
|
elif key == "schools":
|
|
avg = sec.get("avg_rating")
|
|
if avg:
|
|
st.metric("Rating promedio", f"{avg}/10")
|
|
for level, label in [("best_elementary", "Mejor Primaria"),
|
|
("best_middle", "Mejor Middle"),
|
|
("best_high", "Mejor High School")]:
|
|
school = sec.get(level)
|
|
if school:
|
|
st.markdown(
|
|
f"**{label}:** {school.get('name', 'N/A')} "
|
|
f"({school.get('rating', '?')}/10)"
|
|
)
|
|
schools = sec.get("schools", [])[:6]
|
|
if schools:
|
|
st.table({
|
|
"Escuela": [s.get("name", "")[:30] for s in schools],
|
|
"Nivel": [s.get("level", "") for s in schools],
|
|
"Rating": [str(s.get("rating", "N/A")) for s in schools],
|
|
})
|
|
|
|
elif key == "amenities":
|
|
ws = sec.get("walk_score")
|
|
if ws is not None:
|
|
st.metric("Walk Score estimado", f"{ws}/100")
|
|
nearest = sec.get("nearest", {})
|
|
for cat, label in [("supermarket", "🛒 Supermercado"),
|
|
("hospital", "🏥 Hospital"),
|
|
("restaurant", "🍽️ Restaurante"),
|
|
("park", "🌳 Parque")]:
|
|
item = nearest.get(cat)
|
|
if item:
|
|
st.markdown(
|
|
f"**{label}:** {item.get('name', 'N/A')} "
|
|
f"— {item.get('dist_miles', '?')} mi"
|
|
)
|
|
|
|
elif key == "demographics":
|
|
inc = sec.get("median_household_income")
|
|
age = sec.get("median_age")
|
|
unemp = sec.get("unemployment_rate")
|
|
edu = sec.get("education_bachelors_pct")
|
|
pop = sec.get("total_population")
|
|
if inc: st.metric("Ingreso mediano del hogar", f"${inc:,}/año")
|
|
if age: st.metric("Edad mediana", f"{age} años")
|
|
if unemp is not None: st.metric("Tasa de desempleo", f"{unemp}%")
|
|
if edu is not None: st.metric("Con título universitario", f"{edu}%")
|
|
if pop: st.metric("Población", f"{pop:,}")
|
|
eth = sec.get("ethnicity", {})
|
|
if eth:
|
|
st.markdown("**Distribución étnica:**")
|
|
for k, v in eth.items():
|
|
label = k.replace("_pct", "").replace("_", " ").title()
|
|
st.markdown(f"• {label}: {v}%")
|
|
|
|
elif key == "maritime":
|
|
st.metric("Astilleros", len(sec.get("shipyards", [])))
|
|
st.metric("Marinas", len(sec.get("marinas_with_jobs", [])))
|
|
st.metric("Empleadores portuarios",len(sec.get("maritime_employers", [])))
|
|
bls = sec.get("bls_employment", {})
|
|
if bls.get("latest_employment"):
|
|
st.metric("Empleo marítimo estatal",
|
|
f"{bls['latest_employment']}K ({bls.get('year','')})")
|
|
|
|
elif key == "lifestyle":
|
|
nm = sec.get("nearest_marina")
|
|
nb = sec.get("nearest_beach")
|
|
if nm:
|
|
st.markdown(
|
|
f"**⛵ Marina más cercana:** {nm.get('name','N/A')} "
|
|
f"({nm.get('dist_miles','?')} mi)"
|
|
)
|
|
if nb:
|
|
st.markdown(
|
|
f"**🏖️ Playa más cercana:** {nb.get('name','N/A')} "
|
|
f"({nb.get('dist_miles','?')} mi)"
|
|
)
|
|
st.markdown(f"**🌊 Acceso oceánico:** {'Sí ✅' if sec.get('ocean_access') else 'No'}")
|
|
st.markdown(f"**🚤 Boat ramps en 10 mi:** {len(sec.get('boat_ramps', []))}")
|
|
for m in sec.get("marinas", [])[:5]:
|
|
st.markdown(f"• {m.get('name','N/A')} — {m.get('dist_miles','?')} mi")
|
|
|
|
|
|
# ── UI principal ──────────────────────────────────────────────────────────────
|
|
st.title("🏠 Location Intelligence")
|
|
st.caption(
|
|
"Investiga cualquier dirección en EE.UU. — "
|
|
"score 0-100 para decisiones de inversión inmobiliaria"
|
|
)
|
|
|
|
if not ollama_ok():
|
|
st.warning(
|
|
"⚠️ Ollama no está activo — el análisis narrativo estará limitado. "
|
|
"Activa Ollama para obtener síntesis completa."
|
|
)
|
|
|
|
# ── Input + Historial ─────────────────────────────────────────────────────────
|
|
col_input, col_history = st.columns([3, 1])
|
|
|
|
with col_input:
|
|
st.subheader("🔍 Investigar Ubicación")
|
|
|
|
search_mode = st.radio(
|
|
"Modo de búsqueda",
|
|
["🏠 Dirección exacta", "🏙️ Ciudad / Área"],
|
|
horizontal=True,
|
|
label_visibility="collapsed",
|
|
)
|
|
is_city_mode = search_mode == "🏙️ Ciudad / Área"
|
|
|
|
if is_city_mode:
|
|
address_input = st.text_input(
|
|
"Ciudad o área",
|
|
placeholder="Ej: Palm Beach, FL | Daytona Beach, FL | Key West, FL",
|
|
help="Escribe la ciudad y el estado. El análisis usará el centro del área.",
|
|
)
|
|
if address_input.strip() and "," not in address_input:
|
|
st.caption("💡 Agrega el estado para mejores resultados — ej: Miami, FL")
|
|
else:
|
|
address_input = st.text_input(
|
|
"Dirección completa",
|
|
placeholder="Ej: 1234 W 49th St, Hialeah FL 33012",
|
|
help="Incluye ciudad, estado y ZIP para mejores resultados",
|
|
)
|
|
|
|
run_btn = st.button(
|
|
"🚀 Analizar Ubicación",
|
|
type="primary",
|
|
disabled=not address_input.strip(),
|
|
)
|
|
|
|
with col_history:
|
|
st.subheader("📋 Historial")
|
|
try:
|
|
history = list_reports(limit=15)
|
|
if history:
|
|
options = {
|
|
f"#{r['id']} — {r['address'][:32]}… ({r['score_general']}/100)": r["id"]
|
|
for r in history
|
|
}
|
|
sel = st.selectbox(
|
|
"Análisis anteriores",
|
|
["— Nuevo análisis —"] + list(options.keys()),
|
|
)
|
|
if sel != "— Nuevo análisis —" and st.button("📂 Cargar"):
|
|
st.session_state["loaded_report"] = get_report(options[sel])
|
|
st.session_state.pop("current_report", None)
|
|
else:
|
|
st.caption("Sin análisis previos")
|
|
except Exception as e:
|
|
st.caption(f"BD no disponible: {e}")
|
|
|
|
# ── Ejecutar agente ───────────────────────────────────────────────────────────
|
|
if run_btn and address_input.strip():
|
|
status_box = st.empty()
|
|
progress = st.progress(0)
|
|
log_lines: list[str] = []
|
|
|
|
PROGRESS_KEYWORDS = [
|
|
"geocodificando", "sub-agentes", "crime", "property", "schools",
|
|
"amenities", "demographics", "maritime", "lifestyle", "score",
|
|
"ollama", "guardado", "pdf", "completo",
|
|
]
|
|
|
|
def status_cb(msg: str) -> None:
|
|
log_lines.append(msg)
|
|
status_box.markdown("\n".join(f"`{l}`" for l in log_lines[-8:]))
|
|
hits = sum(
|
|
1 for m in log_lines
|
|
if any(k in m.lower() for k in PROGRESS_KEYWORDS)
|
|
)
|
|
progress.progress(min(95, hits * 7))
|
|
|
|
with st.spinner("Analizando ubicación..."):
|
|
try:
|
|
result = run_location_agent(address_input.strip(), status_cb=status_cb)
|
|
st.session_state["current_report"] = result
|
|
st.session_state.pop("loaded_report", None)
|
|
progress.progress(100)
|
|
status_box.empty()
|
|
except Exception as e:
|
|
st.error(f"❌ Error: {e}")
|
|
result = None
|
|
|
|
# ── Mostrar reporte ───────────────────────────────────────────────────────────
|
|
report = (
|
|
st.session_state.get("current_report")
|
|
or st.session_state.get("loaded_report")
|
|
)
|
|
|
|
if not report:
|
|
st.info("👆 Ingresa una dirección arriba y presiona **Investigar Ubicación**")
|
|
st.stop()
|
|
|
|
if report.get("error"):
|
|
st.error(f"❌ {report['error']}")
|
|
st.stop()
|
|
|
|
overall = report.get("overall_score", 0)
|
|
sections = report.get("sections", {})
|
|
scores = report.get("scores", {})
|
|
|
|
st.divider()
|
|
|
|
# Cabecera con score general
|
|
hdr1, hdr2, hdr3 = st.columns([2, 1, 1])
|
|
with hdr1:
|
|
st.markdown(f"### 📍 {report.get('address','')}")
|
|
st.caption(f"Análisis: {report.get('analysis_date','')}")
|
|
with hdr2:
|
|
st.markdown(score_badge(overall), unsafe_allow_html=True)
|
|
st.markdown("<p style='text-align:center'>Score General</p>", unsafe_allow_html=True)
|
|
with hdr3:
|
|
pdf_path = report.get("pdf_path")
|
|
if pdf_path and Path(pdf_path).exists():
|
|
with open(pdf_path, "rb") as f:
|
|
st.download_button(
|
|
"📄 Descargar PDF",
|
|
data=f.read(),
|
|
file_name=Path(pdf_path).name,
|
|
mime="application/pdf",
|
|
)
|
|
|
|
st.divider()
|
|
|
|
# Gráfico de barras por dimensión
|
|
try:
|
|
import plotly.graph_objects as go
|
|
|
|
SCORE_LABELS = {
|
|
"crime": "Seguridad",
|
|
"property": "Inmobiliario",
|
|
"schools": "Escuelas",
|
|
"amenities": "Amenities",
|
|
"demographics": "Demografía",
|
|
"maritime": "Marítimo",
|
|
"lifestyle": "Lifestyle",
|
|
}
|
|
cats = list(SCORE_LABELS.values())
|
|
vals = [scores.get(k, 0) for k in SCORE_LABELS]
|
|
|
|
fig = go.Figure(go.Bar(
|
|
x=cats, y=vals,
|
|
marker_color=[
|
|
"#1a7a1a" if v >= 75 else ("#e6a817" if v >= 50 else "#b83232")
|
|
for v in vals
|
|
],
|
|
text=[f"{v}" for v in vals],
|
|
textposition="outside",
|
|
))
|
|
fig.update_layout(
|
|
title="Scores por Dimensión",
|
|
yaxis=dict(range=[0, 115], title="Score / 100"),
|
|
height=320,
|
|
margin=dict(t=40, b=20),
|
|
plot_bgcolor="rgba(0,0,0,0)",
|
|
)
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
except ImportError:
|
|
cols = st.columns(7)
|
|
for i, (k, lbl) in enumerate(SCORE_LABELS.items()):
|
|
cols[i].metric(lbl, f"{scores.get(k,0)}/100")
|
|
|
|
st.divider()
|
|
|
|
# Resumen ejecutivo
|
|
exec_sec = sections.get("executive", {})
|
|
with st.expander("📋 Resumen Ejecutivo", expanded=True):
|
|
if exec_sec.get("summary"):
|
|
st.write(exec_sec["summary"])
|
|
col_s, col_w = st.columns(2)
|
|
with col_s:
|
|
st.markdown("**✅ Fortalezas**")
|
|
for s in exec_sec.get("strengths", []):
|
|
st.markdown(f"• {s}")
|
|
with col_w:
|
|
st.markdown("**⚠️ Riesgos**")
|
|
for w in exec_sec.get("weaknesses", []):
|
|
st.markdown(f"• {w}")
|
|
|
|
# Secciones detalladas
|
|
SECTION_ORDER = [
|
|
("crime", "🔒 Criminalidad"),
|
|
("property", "🏘️ Mercado Inmobiliario"),
|
|
("schools", "🎓 Escuelas"),
|
|
("amenities", "🛒 Amenities y Walkability"),
|
|
("demographics", "👥 Demografía"),
|
|
("maritime", "⚓ Mercado Laboral Marítimo"),
|
|
("lifestyle", "⛵ Estilo de Vida Náutico"),
|
|
]
|
|
|
|
for key, label in SECTION_ORDER:
|
|
sec = sections.get(key, {})
|
|
sec_score = sec.get("score", 0)
|
|
clr = score_color(sec_score)
|
|
|
|
with st.expander(f"{label} — :{clr}[**{sec_score}/100**]"):
|
|
col_data, col_narr = st.columns(2)
|
|
|
|
with col_data:
|
|
render_section_data(key, sec)
|
|
if sec.get("errors"):
|
|
for err in sec["errors"]:
|
|
st.caption(f"⚠️ {err}")
|
|
if sec.get("sources"):
|
|
st.caption(f"Fuentes: {', '.join(sec['sources'])}")
|
|
|
|
with col_narr:
|
|
if sec.get("narrative"):
|
|
st.markdown("**Análisis:**")
|
|
st.write(sec["narrative"])
|
|
else:
|
|
st.caption("Análisis narrativo no disponible (Ollama inactivo)")
|