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