Files
2026-07-03 12:24:58 -04:00

3824 lines
169 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AR-House Streamlit App
==========================
Single-page dashboard para analisis de inversion en bienes raices Florida.
Pipeline: 5 agentes Ollama secuenciales (PhotoInspector -> DealAnalyzer ->
FloridaResearcher -> LenderMatcher -> Coordinator) + EmailComposer on-demand.
Paginas:
- Analisis manual (Phase 2C, funcional)
- Historico (lista analyses/*.json)
- Feed de deals (placeholder Phase 3E)
- Mercados (placeholder Phase 3E)
"""
from __future__ import annotations
import io
import json
import os
import re
import urllib.parse
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
import requests
import streamlit as st
from dotenv import load_dotenv
from PIL import Image
load_dotenv()
import config
from orchestrator import (
DealInputs,
BuyerProfile,
analyze_deal,
compose_email,
)
from prompt_templates import EMAIL_TYPES, PROFILE_DESCRIPTIONS
# ════════════════════════════════════════════════════════════════════════════
# Page config
# ════════════════════════════════════════════════════════════════════════════
st.set_page_config(
page_title="AR-House",
page_icon=None,
layout="wide",
initial_sidebar_state="expanded",
)
# Professional styling — corporate palette inspired by Bloomberg/Stripe.
# Injected once at app load; affects every page.
st.markdown(
"""
<style>
/* Hide deploy button + hamburger menu, but KEEP the toolbar so the
Streamlit "Stop" button stays visible (essential to cancel long scrapes). */
#MainMenu { visibility: hidden; }
footer { visibility: hidden; }
[data-testid="stDeployButton"] { display: none !important; }
[data-testid="stToolbar"] { right: 1rem; }
html, body, [class*="css"] {
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", sans-serif !important;
color: #0F172A;
}
h1, h2, h3, h4 {
font-weight: 700 !important;
letter-spacing: -0.02em !important;
color: #0F172A !important;
}
h1 { font-size: 28px !important; margin-bottom: 4px !important; }
h2 { font-size: 22px !important; }
h3 { font-size: 18px !important; }
.stCaption, .stMarkdown small {
color: #64748B !important;
font-size: 13px !important;
}
.deal-card {
border: 1px solid #E2E8F0;
border-radius: 12px;
padding: 18px;
background: white;
margin-bottom: 14px;
transition: all 0.15s ease;
}
.deal-card:hover {
border-color: #94A3B8;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06);
}
.deal-card-img {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 8px;
background: #F1F5F9;
}
.deal-card-img-placeholder {
width: 100%;
height: 160px;
background: linear-gradient(135deg, #F1F5F9 0%, #E2E8F0 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #94A3B8;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 500;
}
.badge {
display: inline-block;
padding: 3px 9px;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.badge-go { background: #ECFDF5; color: #047857; }
.badge-maybe { background: #FEF3C7; color: #B45309; }
.badge-nogo { background: #FEE2E2; color: #B91C1C; }
.badge-winner { background: #D1FAE5; color: #065F46; }
.badge-redflag { background: #FEE2E2; color: #991B1B; }
.badge-pass { background: #F1F5F9; color: #64748B; }
.badge-neutral { background: #F1F5F9; color: #475569; }
.badge-info { background: #DBEAFE; color: #1E40AF; }
.badge-source { background: #EEF2FF; color: #4338CA; }
.metric-label {
color: #64748B;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 500;
margin-bottom: 2px;
}
.metric-value {
color: #0F172A;
font-size: 20px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.metric-value-small { font-size: 14px; }
.stButton button {
border-radius: 8px !important;
font-weight: 500 !important;
font-size: 13px !important;
padding: 6px 14px !important;
border: 1px solid #E2E8F0 !important;
background: white !important;
color: #0F172A !important;
box-shadow: none !important;
transition: all 0.15s !important;
}
.stButton button:hover {
border-color: #94A3B8 !important;
background: #F8FAFC !important;
}
.stButton button[kind="primary"] {
background: #1E3A8A !important;
color: white !important;
border-color: #1E3A8A !important;
}
.stButton button[kind="primary"]:hover { background: #1E40AF !important; }
.stDataFrame, .dataframe { font-variant-numeric: tabular-nums; font-size: 13px !important; }
section[data-testid="stSidebar"] {
background: #F8FAFC;
border-right: 1px solid #E2E8F0;
}
section[data-testid="stSidebar"] h1 { font-size: 20px !important; }
.stTextInput input, .stNumberInput input, .stSelectbox select {
border-radius: 8px !important;
border: 1px solid #E2E8F0 !important;
font-size: 14px !important;
}
.streamlit-expanderHeader { font-weight: 500 !important; font-size: 14px !important; }
</style>
""",
unsafe_allow_html=True,
)
# ════════════════════════════════════════════════════════════════════════════
# Helpers - formato y parsing
# ════════════════════════════════════════════════════════════════════════════
def fmt_money(x) -> str:
"""Format number as $X,XXX. Returns '—' for None/empty/invalid."""
if x is None or x == "":
return "—"
try:
return f"${float(x):,.0f}"
except (TypeError, ValueError):
return "—"
@st.cache_data(ttl=300, show_spinner=False)
def fetch_firecrawl_credits() -> Optional[dict]:
"""Llama a la API de Firecrawl para saldo. Cache 5 min. None si no key/error."""
key = os.getenv("FIRECRAWL_API_KEY", "")
if not key:
return None
try:
r = requests.get(
"https://api.firecrawl.dev/v1/team/credit-usage",
headers={"Authorization": f"Bearer {key}"},
timeout=10,
)
if r.status_code == 200:
return r.json().get("data")
except Exception:
pass
return None
# Regex para parsear el output de Coordinator (formato 7 puntos).
# Tolerante a variaciones de markdown (**, ###, espacios).
VERDICT_RE = re.compile(
r"\bVeredicto\s*[:\-]?\s*\**\s*"
r"(PASA\s+CON\s+CONDICIONES|NO\s+PASA|PASA|CONDICIONES)"
r"\s*\**[^(]*\(\s*[Ss]core\s*[:\-]?\s*(\d+)\s*/\s*10\s*\)",
re.IGNORECASE,
)
def parse_verdict(markdown: str) -> Optional[Tuple[str, int]]:
"""Devuelve (veredicto_canonico, score 1-10) o None si no parseable."""
m = VERDICT_RE.search(markdown)
if not m:
return None
raw = m.group(1).upper()
if "NO" in raw:
verdict = "NO PASA"
elif "CONDICIONES" in raw:
verdict = "PASA CON CONDICIONES"
else:
verdict = "PASA"
try:
score = int(m.group(2))
except (TypeError, ValueError):
return None
return verdict, score
def parse_financial_metrics(markdown: str) -> dict:
"""Best-effort extraction de DSCR/CashFlow/CapRate/CoC del DealAnalyzer."""
metrics: dict = {}
m = re.search(r"DSCR[^\d\-]{0,40}([\d]+\.[\d]+)", markdown, re.IGNORECASE)
if m:
metrics["DSCR"] = m.group(1)
m = re.search(
r"Cash[\s_\-]*Flow[^\$\d\-+]{0,40}([+\-]?\$?\s*[\d,]+(?:\.\d+)?)",
markdown,
re.IGNORECASE,
)
if m:
cf = m.group(1).replace(" ", "")
if not cf.startswith(("$", "-", "+")):
cf = "$" + cf
metrics["Cash Flow"] = f"{cf}/mo"
m = re.search(r"Cap[\s_\-]*Rate[^\d\-]{0,40}([\d]+(?:\.[\d]+)?)\s*%", markdown, re.IGNORECASE)
if m:
metrics["Cap Rate"] = f"{m.group(1)}%"
m = re.search(
r"Co(?:C|sh)[\s\-_]*(?:Return)?[^\d\-+]{0,40}([\-+]?[\d]+(?:\.[\d]+)?)\s*%",
markdown,
re.IGNORECASE,
)
if m:
metrics["CoC"] = f"{m.group(1)}%"
return metrics
VERDICT_BADGE = {
"PASA": ("", "#047857", "PASA"),
"PASA CON CONDICIONES": ("", "#B45309", "PASA CON CONDICIONES"),
"NO PASA": ("", "#B91C1C", "NO PASA"),
}
def list_history_files() -> list[Path]:
p = Path(config.ANALYSES_DIR)
if not p.exists():
return []
return sorted(p.glob("*.json"), reverse=True)
def load_analysis_json(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
return json.load(f)
def get_field(obj, key: str):
"""Acceso uniforme: si obj es dataclass usa attr, si es dict usa key."""
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
def normalize_analysis(result) -> dict:
"""Convierte AnalysisResult dataclass o dict en dict estandar para render."""
if isinstance(result, dict):
return result
# Dataclass: usar asdict para incluir TODOS los campos (incluidos
# los Wave 2: executive_briefing, value_estimate, offer_strategy,
# property_value_data, verified_data, computed_scenarios, y
# price_validation de Bug 2). Antes el normalizador listaba campos
# explicitos y se perdian datos en fresh runs.
try:
from dataclasses import asdict, is_dataclass
if is_dataclass(result):
return asdict(result)
except Exception:
pass
# Fallback: campos minimos si asdict fallo
return {
"deal": result.deal,
"profile": result.profile,
"photo": result.photo if isinstance(result.photo, dict) else {
"output": result.photo.output, "seconds": result.photo.seconds,
"tokens": result.photo.tokens, "error": result.photo.error,
"agent": result.photo.agent,
},
"deal_analysis": result.deal_analysis if isinstance(result.deal_analysis, dict) else {
"output": result.deal_analysis.output, "seconds": result.deal_analysis.seconds,
"tokens": result.deal_analysis.tokens, "error": result.deal_analysis.error,
"agent": result.deal_analysis.agent,
},
"research": result.research if isinstance(result.research, dict) else {
"output": result.research.output, "seconds": result.research.seconds,
"tokens": result.research.tokens, "error": result.research.error,
"agent": result.research.agent,
},
"lender": result.lender if isinstance(result.lender, dict) else {
"output": result.lender.output, "seconds": result.lender.seconds,
"tokens": result.lender.tokens, "error": result.lender.error,
"agent": result.lender.agent,
},
"final": result.final if isinstance(result.final, dict) else {
"output": result.final.output, "seconds": result.final.seconds,
"tokens": result.final.tokens, "error": result.final.error,
"agent": result.final.agent,
},
"started_at": result.started_at,
"finished_at": result.finished_at,
"total_seconds": result.total_seconds,
}
# ════════════════════════════════════════════════════════════════════════════
# Session state
# ════════════════════════════════════════════════════════════════════════════
def init_state():
defaults = {
"last_analysis": None, # AnalysisResult o dict (loaded del historico)
"last_email_output": None, # str markdown del email generado
"show_email_section": False,
"save_toast_shown": False,
}
for k, v in defaults.items():
st.session_state.setdefault(k, v)
# ════════════════════════════════════════════════════════════════════════════
# Form state helpers (Fix #1: inputs vacios + carga opcional de deal ejemplo)
# ════════════════════════════════════════════════════════════════════════════
# Valores del deal de prueba (Hialeah foreign national colombiano)
EXAMPLE_DEAL_VALUES = {
"form_address": "1234 W 49th St, Hialeah FL 33012",
"form_price": 385000,
"form_rent": 2800,
"form_ptax": 7200,
"form_ins": 4800,
"form_hoa": 0,
"form_beds": 3,
"form_baths": 2.0,
"form_sqft": 1450,
"form_year_built": 1972,
"form_arv": 440000,
"form_rehab_override": 0,
"form_profile_class": "C",
"form_fico": 0,
"form_seasoning": 0.0,
"form_capital": 0,
"form_experience": 0,
"form_nationality": "Colombia",
"form_visa": "",
"form_deal_type": "mls",
"form_case_number": "",
"form_occupancy": "unknown",
}
# Defaults vacios/cero — estado inicial del formulario.
# No se prellena nada para evitar que el usuario analice por error datos de ejemplo.
FORM_DEFAULTS = {
"form_address": "",
"form_price": 0,
"form_rent": 0,
"form_ptax": 0,
"form_ins": 0,
"form_hoa": 0,
"form_beds": 0,
"form_baths": 0.0,
"form_sqft": 0,
"form_year_built": 0,
"form_arv": 0,
"form_rehab_override": 0,
"form_profile_class": "A",
"form_fico": 0,
"form_seasoning": 0.0,
"form_capital": 0,
"form_experience": 0,
"form_nationality": "",
"form_visa": "",
"form_deal_type": "mls",
"form_case_number": "",
"form_occupancy": "unknown",
"form_property_type": "sfr",
"form_listing_description": "",
}
# Etiquetas legibles para el selectbox de tipo de deal
DEAL_TYPE_LABELS = {
"mls": "MLS / listado normal",
"off_market": "Off-market (private deal)",
"auction": "Auction (genérico)",
"foreclosure": "Auction — Foreclosure (subasta hipotecaria)",
"tax_deed": "Auction — Tax Deed (impuestos impagos)",
"reo": "REO (Bank-Owned post-foreclosure)",
}
OCCUPANCY_LABELS = {
"unknown": "Desconocido / no inspeccionado",
"vacant": "Vacante (desocupada)",
"occupied": "Ocupada (squatter o ex-owner)",
}
# Tipos de propiedad (Property Type bugfix — Vero Beach LAND case)
PROPERTY_TYPE_LABELS = {
"sfr": "SFR — Single Family Residence",
"condo": "Condo",
"townhome": "Townhome",
"multi_family": "Multi-family (24 units)",
"land": "Terreno / Vacant lot",
"mobile_home": "Mobile / Manufactured home",
"commercial": "Commercial / Mixed-use",
}
def _init_form_state():
"""Inicializa session_state con defaults vacios si las keys no existen."""
for k, v in FORM_DEFAULTS.items():
st.session_state.setdefault(k, v)
def _load_example_deal():
"""Llena el formulario con un deal de prueba (Hialeah foreign national)."""
for k, v in EXAMPLE_DEAL_VALUES.items():
st.session_state[k] = v
def _clear_form():
"""Vuelve todos los inputs del formulario a defaults vacios."""
for k, v in FORM_DEFAULTS.items():
st.session_state[k] = v
# ════════════════════════════════════════════════════════════════════════════
# Sidebar
# ════════════════════════════════════════════════════════════════════════════
PAGES = {
"manual": "Análisis",
"search": "Búsqueda",
"inventory": "Inventario",
"favorites": "Favoritos",
"history": "Histórico",
"feed": "Feed",
"markets": "Mercados",
}
def render_sidebar() -> str:
st.sidebar.title("AR-House")
st.sidebar.caption("Florida real estate analysis platform")
# key="nav_page" permite cambiar de pagina programaticamente
# (e.g., desde el boton "Analizar a fondo" del Inventario)
page = st.sidebar.radio(
"Navegación",
options=list(PAGES.keys()),
format_func=lambda k: PAGES[k],
label_visibility="collapsed",
key="nav_page",
)
st.sidebar.divider()
# Counter de favoritos — incentivo visual para usar la pagina
try:
from deals_db import init_db, list_deals
init_db()
fav_count = len(list_deals(status=config.DEAL_STATUS_INTERESTING, limit=10000))
if fav_count > 0:
st.sidebar.metric("Favoritos", fav_count,
help="Deals marcados como interesantes")
except Exception:
pass # silently skip si DB no esta lista
# Firecrawl credits status
credits = fetch_firecrawl_credits()
if credits:
rem = credits.get("remaining_credits", 0)
plan = credits.get("plan_credits", 1000)
st.sidebar.metric(
"Firecrawl créditos",
f"{rem} / {plan}",
help=f"Presupuesto interno: {config.FIRECRAWL_CREDIT_BUDGET}",
)
pct = (rem / plan * 100) if plan else 0
if pct < 20:
st.sidebar.error(f"Quedan {pct:.0f}% del mes")
elif pct < 50:
st.sidebar.warning(f"Quedan {pct:.0f}% del mes")
else:
st.sidebar.caption("Firecrawl: API key no configurada o sin conexión")
st.sidebar.caption(f"Modo recursos: `{config.RESOURCE_MODE}`")
st.sidebar.caption("v0.1 · Phase 2C")
return page
# ════════════════════════════════════════════════════════════════════════════
# Page: Manual Analysis
# ════════════════════════════════════════════════════════════════════════════
def page_manual_analysis():
st.title("Análisis manual de propiedad")
st.caption("Pegá los datos del listing, subí fotos, los 5 agentes hacen el resto.")
# Banner si los datos vienen pre-cargados desde Inventario o Search
prefill_info = st.session_state.get("prefilled_from_inventory")
if prefill_info and st.session_state.last_analysis is None:
with st.container(border=True):
st.success(
f"**Datos precargados** desde `{prefill_info.get('source')}` · "
f"deal #{prefill_info.get('deal_id')} · "
f"_{prefill_info.get('address', '?')[:60]}_"
)
st.markdown(
"**Campos que el scraper NO trae** y necesitás completar antes de Analizar:"
"\n- **Rent estimado** (mensual) — checkear HUD FMR o Rentometer"
"\n- **Property tax** (anual) — ~1.82.2% del precio en FL típico"
"\n- **Insurance** (anual) — desde ~$3K SFR inland hasta $10K+ costa"
"\n- **HOA** (mensual) — 0 si SFR sin asociación"
"\n- **ARV** (After Repair Value) — investigar comps recientes"
)
if st.button("Limpiar banner", key="clear_prefill_banner"):
del st.session_state["prefilled_from_inventory"]
st.rerun()
if st.session_state.last_analysis is None:
_render_input_form()
else:
_render_results()
st.divider()
if st.button("Nuevo análisis", use_container_width=True):
st.session_state.last_analysis = None
st.session_state.last_email_output = None
st.session_state.show_email_section = False
st.session_state.save_toast_shown = False
st.rerun()
def _render_input_form():
_init_form_state()
# ---------- Quick actions ----------
c1, c2 = st.columns([1, 1])
with c1:
if st.button(
"Cargar deal de prueba (Hialeah)",
help="Llena el formulario con un deal de ejemplo de Hialeah FL para ver el sistema en acción",
use_container_width=True,
):
_load_example_deal()
st.rerun()
with c2:
if st.button(
"Limpiar formulario",
help="Vacía todos los campos",
use_container_width=True,
):
_clear_form()
st.rerun()
# ---------- 1. Inputs del deal ----------
with st.expander("1. Datos de la propiedad", expanded=True):
c1, c2 = st.columns(2)
with c1:
st.text_input(
"Dirección completa",
key="form_address",
placeholder="Ej: 1234 W 49th St, Hialeah FL 33012",
)
st.number_input("Precio listado (USD)", min_value=0, step=1000, key="form_price")
st.number_input("Renta mensual estimada (USD)", min_value=0, step=50, key="form_rent")
st.number_input("Property tax anual (USD)", min_value=0, step=100, key="form_ptax")
st.number_input("Seguro anual (USD)", min_value=0, step=100, key="form_ins")
st.number_input("HOA mensual (USD)", min_value=0, step=10, key="form_hoa")
with c2:
st.number_input("Beds", min_value=0, max_value=20, step=1, key="form_beds")
st.number_input("Baths", min_value=0.0, max_value=20.0, step=0.5, key="form_baths")
st.number_input("Sqft", min_value=0, step=10, key="form_sqft")
st.number_input(
"Año construcción (0 = no provisto)",
min_value=0, max_value=2030, step=1, key="form_year_built",
)
st.number_input("ARV estimado (USD)", min_value=0, step=1000, key="form_arv")
st.number_input(
"Rehab override (opcional, 0 = lo estima PhotoInspector)",
min_value=0, step=500, key="form_rehab_override",
)
# ---------- 1.4 Tipo de propiedad (LAND bugfix) ----------
with st.expander("1.4 Tipo de propiedad", expanded=True):
c1, c2 = st.columns([2, 1])
with c1:
st.selectbox(
"Tipo de propiedad",
options=list(PROPERTY_TYPE_LABELS.keys()),
format_func=lambda k: PROPERTY_TYPE_LABELS[k],
key="form_property_type",
help=(
"**CRÍTICO**: el sistema asume SFR por default. Si esto es TERRENO/lote vacante, "
"el análisis financiero (Cap Rate, DSCR, CoC) NO aplica — necesita pipeline distinto."
),
)
if st.session_state.form_property_type == "land":
st.info(
"**TERRENO detectado.** El pipeline SFR (DealAnalyzer / LenderMatcher / "
"ValueEstimator / OfferStrategist) será **bypassado**. El sistema correrá "
"solo Researcher + Coordinator + Briefing en modo LAND."
)
elif st.session_state.form_property_type in ("commercial", "mobile_home"):
st.warning(
f"**{PROPERTY_TYPE_LABELS[st.session_state.form_property_type]}**: pipeline SFR "
"no aplica completamente. El sistema correrá modo simplificado."
)
with c2:
st.text_area(
"Descripción del listing (opcional)",
key="form_listing_description",
height=100,
help=(
"Texto del listing original (Zillow/Realtor/MLS). El sistema busca keywords como "
"'vacant lot', 'buildable', 'mobile home', 'commercial' para detectar mismatches "
"automáticamente."
),
)
# ---------- 1.5 Tipo de deal (MLS / auction / foreclosure / tax_deed / REO) ----------
with st.expander("1.5 Tipo de transacción", expanded=True):
c1, c2 = st.columns([2, 1])
with c1:
st.selectbox(
"Tipo de deal",
options=list(DEAL_TYPE_LABELS.keys()),
format_func=lambda k: DEAL_TYPE_LABELS[k],
key="form_deal_type",
help=(
"MLS = listado normal de un agente. "
"Auction/foreclosure/tax_deed = subastas (CASH-ONLY, sin inspección, deudas heredables). "
"REO = banco vende post-foreclosure (más limpio que auction)."
),
)
is_auction_form = st.session_state.form_deal_type in ("auction", "foreclosure", "tax_deed", "reo")
with c2:
if is_auction_form:
st.text_input(
"Case number / Auction ID",
key="form_case_number",
placeholder="ej: 2015-026484-CA-01",
help="Número de expediente del clerk para validar status del case (postponed, canceled, redeemed).",
)
if is_auction_form:
st.selectbox(
"Estado de ocupación",
options=list(OCCUPANCY_LABELS.keys()),
format_func=lambda k: OCCUPANCY_LABELS[k],
key="form_occupancy",
help=(
"Si la propiedad está OCCUPIED, hay que reservar $5K-$15K para eviction "
"y planificar +30-90 días adicionales antes de poder rehab/rentar."
),
)
st.warning(
"**AUCTION DEALS — riesgos especiales:**\n"
"- **Cash-only**: no hay financiamiento al cierre. Necesitás MAB completo + reserves en efectivo.\n"
"- **Sin inspección previa**: el rehab puede ser 1.5x-2x del estimado.\n"
"- **Deudas heredables**: property tax delinquente, IRS liens (120-day redemption), HOA dues, code violations.\n"
"- **Title issues**: hacer title search profesional ANTES del bid ($300-$500).\n"
"- **Tax deed**: 1-year redemption period donde el ex-owner puede recomprar.\n"
"- **Occupied**: eviction $5K-$15K + 30-90 días adicionales (Florida tiene proceso legal estricto).\n\n"
"Si Veredicto del auction sale **NO BID**, NO biddear — significa que no hay margen de safety."
)
else:
# Resetear case_number/occupancy si dejaste de ser auction
# (no clear: el usuario podria querer guardarlos para retoggle)
pass
# ---------- 2. Perfil del comprador ----------
with st.expander("2. Perfil del comprador", expanded=True):
c1, c2 = st.columns(2)
with c1:
st.selectbox(
"Clase de perfil",
options=["A", "B", "C", "D"],
format_func=lambda k: f"{k}{PROFILE_DESCRIPTIONS[k]}",
key="form_profile_class",
)
st.number_input("FICO (0 = no provisto)", min_value=0, max_value=850, step=10, key="form_fico")
st.number_input(
"LLC seasoning (años, 0 = N/A)",
min_value=0.0, max_value=20.0, step=0.5, key="form_seasoning",
)
with c2:
st.number_input(
"Capital disponible (USD, 0 = no provisto)",
min_value=0, step=1000, key="form_capital",
)
st.number_input(
"Deals previos cerrados (0 = no provisto)",
min_value=0, max_value=999, step=1, key="form_experience",
)
st.text_input("Nacionalidad (vacío = USA asumido)", key="form_nationality")
st.text_input("Estatus visa (vacío = N/A)", key="form_visa")
# ---------- 3. Fotos ----------
photos_to_send = _render_photo_uploader()
# ---------- 4. Validacion de campos criticos ----------
st.divider()
s = st.session_state
missing: list[str] = []
if not s.form_address.strip():
missing.append("Dirección")
if s.form_price <= 0:
missing.append("Precio listado")
if s.form_rent <= 0:
missing.append("Renta mensual")
if s.form_ptax <= 0:
missing.append("Property tax anual")
if s.form_ins <= 0:
missing.append("Seguro anual")
if missing:
st.warning(
"**Faltan campos críticos** para correr el análisis: **"
+ ", ".join(missing)
+ "**.\n\nLlená estos campos con datos reales del deal, "
"o usá **'Cargar deal de prueba'** arriba si solo querés probar el sistema."
)
# ---------- 5. Boton Analizar ----------
eta_min = config.ANALYSIS_ETA_SECONDS // 60
eta_sec = config.ANALYSIS_ETA_SECONDS % 60
eta_str = f"~{eta_min}:{eta_sec:02d} min"
can_run = (not missing) and (photos_to_send is not None)
btn_label = f"Analizar deal (ETA {eta_str})"
if st.button(btn_label, type="primary", use_container_width=True, disabled=not can_run):
deal = DealInputs(
address=s.form_address.strip(),
price=float(s.form_price),
rent=float(s.form_rent),
property_tax=float(s.form_ptax),
insurance=float(s.form_ins),
hoa=float(s.form_hoa),
sqft=int(s.form_sqft),
beds=int(s.form_beds),
baths=float(s.form_baths),
year_built=int(s.form_year_built),
arv=float(s.form_arv),
rehab_override=float(s.form_rehab_override) if s.form_rehab_override > 0 else None,
deal_type=s.form_deal_type,
case_number=s.form_case_number.strip() or None if s.form_deal_type != "mls" else None,
occupancy_status=s.form_occupancy if s.form_deal_type != "mls" else None,
property_type=s.form_property_type,
listing_description=s.form_listing_description.strip() or None,
)
profile = BuyerProfile(
profile_class=s.form_profile_class,
fico=int(s.form_fico) if s.form_fico > 0 else None,
llc_seasoning_years=float(s.form_seasoning) if s.form_seasoning > 0 else None,
capital_available=float(s.form_capital) if s.form_capital > 0 else None,
experience_deals=int(s.form_experience) if s.form_experience > 0 else None,
nationality=s.form_nationality.strip() or None,
visa_status=s.form_visa.strip() or None,
)
with st.status("⏳ Analizando deal · 5 agentes secuenciales", expanded=True) as status:
def cb(msg: str):
status.write(msg)
try:
result = analyze_deal(
deal=deal,
profile=profile,
photo_bytes=photos_to_send if photos_to_send else None,
status_cb=cb,
)
status.update(
label=f"Análisis completo en {result.total_seconds:.0f}s",
state="complete",
)
st.session_state.last_analysis = result
st.rerun()
except Exception as e:
status.update(label=f"Error: {e}", state="error")
st.exception(e)
def _render_photo_uploader() -> Optional[list[bytes]]:
"""Devuelve list[bytes] (puede ser vacia), o None si user seleccionó >6."""
with st.expander("3. Fotos del listing (opcional, máx 6 al agente)", expanded=True):
uploaded = st.file_uploader(
"Subí 1-N fotos del listing. Si subís más de 6, te dejo elegir cuáles 6 mandar.",
accept_multiple_files=True,
type=["jpg", "jpeg", "png", "webp"],
)
if not uploaded:
st.caption("Sin fotos → PhotoInspector se omitirá. DealAnalyzer usará rehab override si lo diste.")
return []
if len(uploaded) <= 6:
st.success(f"{len(uploaded)} foto(s) listas para PhotoInspector.")
return [f.getvalue() for f in uploaded]
# >6 → checkbox UI
st.warning(
f"Subiste {len(uploaded)} fotos. Elegí máximo 6 "
"(las primeras 6 marcadas por default)."
)
cols = st.columns(4)
selected: list[int] = []
for i, f in enumerate(uploaded):
with cols[i % 4]:
try:
img = Image.open(io.BytesIO(f.getvalue()))
st.image(img, use_container_width=True, caption=f"#{i+1} {f.name}")
except Exception:
st.caption(f"#{i+1} (preview falló)")
default = i < 6
if st.checkbox(f"Usar #{i+1}", value=default, key=f"photo_chk_{i}"):
selected.append(i)
if len(selected) > 6:
st.error(f"Seleccionaste {len(selected)}. Quitá {len(selected)-6} para continuar.")
return None
if not selected:
st.info("No marcaste ninguna foto — PhotoInspector se omitirá.")
return []
st.success(f"{len(selected)} foto(s) seleccionadas.")
return [uploaded[i].getvalue() for i in selected]
# ─── Render de resultados ────────────────────────────────────────────────────
def _render_results():
result = normalize_analysis(st.session_state.last_analysis)
deal_d = result["deal"]
final_out = result["final"]["output"]
deal_an_out = result["deal_analysis"]["output"]
photo_out = result["photo"]["output"]
research_out = result["research"]["output"]
lender_out = result["lender"]["output"]
briefing = result.get("executive_briefing") or {}
briefing_out = briefing.get("output") if isinstance(briefing, dict) else None
# ─── 0z. ALERTA DE PROPERTY TYPE (LAND bugfix: detectó mismatch SFR/Land) ─
ptw = result.get("property_type_warning") or {}
pipeline_mode = result.get("pipeline_mode", "full_sfr")
if ptw.get("warnings") or pipeline_mode == "land_simplified":
_render_property_type_warning(ptw, pipeline_mode)
# ─── 0a. ALERTA DE PRECIO (Bug 2: RED FLAG masivo si discrepancia >=30%) ──
price_validation = result.get("price_validation") or {}
_render_price_red_flag(price_validation)
# ─── 0b. ANOMALIAS FINANCIERAS (Bug 3: "demasiado bueno para ser verdad") ─
computed_scenarios = result.get("computed_scenarios") or {}
anomalies = computed_scenarios.get("anomalies") or {}
_render_anomalies_alert(anomalies)
# ─── 1. BRIEFING EJECUTIVO (NUEVO, prominente arriba) ─────────────────────
_render_briefing_section(briefing_out, briefing if isinstance(briefing, dict) else {})
# ─── 2. Veredicto coordinador + botones de accion ─────────────────────────
st.divider()
_render_verdict_section(final_out, deal_d, result)
# ─── 3. Analisis tecnico completo (colapsado por default) ─────────────────
st.divider()
with st.expander("Análisis técnico completo (para revisión detallada)", expanded=False):
st.caption("Output crudo de cada agente especialista — útil para auditar la cadena de razonamiento.")
st.markdown("### Análisis financiero (DealAnalyzer)")
_render_financial_section(deal_an_out)
st.markdown("### Inspección de fotos (PhotoInspector)")
_render_agent_block_inline(result["photo"])
st.markdown("### Mercado y barrio (FloridaResearcher)")
_render_agent_block_inline(result["research"])
st.markdown("### Financiamiento (LenderMatcher)")
_render_agent_block_inline(result["lender"])
# Wave 2: ValueEstimator + OfferStrategist
value_estimate = result.get("value_estimate") or {}
if value_estimate.get("output"):
st.markdown("### ValueEstimator (valor real vs listing)")
_render_agent_block_inline(value_estimate)
# Mostrar property_value_data raw como sub-expander dentro
pv = result.get("property_value_data") or {}
if pv:
with st.expander("Datos pre-calculados (property_value)", expanded=False):
st.json(pv)
offer_strategy = result.get("offer_strategy") or {}
if offer_strategy.get("output"):
st.markdown("### OfferStrategist (Strike/Stretch/Walk-Away + estrategia)")
_render_agent_block_inline(offer_strategy)
st.markdown("### Veredicto coordinador (Coordinator, markdown crudo)")
st.markdown(final_out)
# ─── 4. Email composer (colapsado) ────────────────────────────────────────
st.divider()
_render_email_section(deal_d, result)
def _render_price_red_flag(price_validation: dict):
"""Bug 2: Renderea la alerta de price discrepancy al tope de los resultados.
Si CRITICAL_RED_FLAG: st.error gigante con expander expandido por default,
listando posibles razones + investigacion mandatoria.
Si WARNING: st.warning compacto.
Si NORMAL: una nota verde discreta confirmando.
Si UNKNOWN: una info gris suave (no alarma).
"""
if not price_validation or not isinstance(price_validation, dict):
return
status = price_validation.get("status")
listing = price_validation.get("listing_price")
signed = price_validation.get("signed_max_discrepancy_pct") or 0
estimates = price_validation.get("market_estimates") or {}
sources = price_validation.get("sources_used") or []
recommendation = price_validation.get("recommendation", "")
direction = "BAJO" if signed < 0 else "SOBRE"
# Wave 1.5A: CONFIRMED_DISTRESSED es el nivel mas alto (court records confirmaron)
if status == "CONFIRMED_DISTRESSED":
court = price_validation.get("court_records") or {}
owner = court.get("owner_name", "?")
lp_count = court.get("lis_pendens_count", 0)
mismatch = price_validation.get("deal_type_mismatch", False)
st.error(
f"**CONFIRMED DISTRESSED — LIS PENDENS ACTIVO** · "
f"Court records confirman {lp_count} caso(s) de foreclosure pendiente. "
f"Owner: **{owner}**. Esto NO es hipótesis — es hecho judicial verificable."
)
if mismatch:
st.warning(
"**DEAL_TYPE MISMATCH** · El usuario ingresó este deal como **MLS** pero "
"court records dicen FORECLOSURE. El sistema NO cambió el deal_type automáticamente — "
"**verificar manualmente y re-correr** con `deal_type='foreclosure'` para que "
"las métricas usen la fórmula MAB (auction) en vez de Strike/Stretch (MLS). "
"Los números actuales pueden estar muy desviados."
)
with st.expander("Detalles del lis pendens detectado", expanded=True):
st.markdown(f"**Recomendación:** {price_validation.get('recommendation', '')}")
st.markdown("---")
st.markdown(f"**Property Appraiser data:**")
st.markdown(f"- Owner: `{owner}`")
st.markdown(f"- RE Number: `{court.get('re_number', '?')}`")
st.markdown(f"- Year built (oficial): {court.get('year_built_official', 'N/A')}")
lp_list = court.get("lis_pendens") or []
if lp_list:
st.markdown("**Casos lis pendens activos:**")
for c in lp_list[:5]:
st.markdown(
f"- Filed: `{c.get('filing_date', '?')}` | "
f"Instrument: `{c.get('instrument_number', '?')}` | "
f"Doc: `{c.get('doc_type', '?')}`"
)
# Wave 1.5A v1.2: PLAYERS — quien demanda
plaintiff = court.get("plaintiff") or {}
if plaintiff and plaintiff.get("name"):
st.markdown("---")
st.markdown("### Players — quien demanda")
st.markdown(f"- **Plaintiff:** `{plaintiff['name']}`")
st.markdown(f"- **Tipo:** `{plaintiff.get('type', '?')}` (categoría: {plaintiff.get('category', '?')})")
if plaintiff.get("is_original_loan_holder") is True:
st.markdown("- Plaintiff **ES** el originador del loan")
elif plaintiff.get("is_original_loan_holder") is False:
st.markdown("- Plaintiff **NO es** el originador (servicer / GSE / trustee / tax authority)")
if plaintiff.get("note"):
st.info(plaintiff["note"])
# Wave 1.5A v1.2: LIENS INVENTORY
all_liens = court.get("all_liens") or []
total_surviving = court.get("total_surviving_debt", 0) or 0
liens_status = court.get("liens_detail_status")
investor_warning = court.get("investor_warning")
st.markdown("---")
st.markdown("### Liens inventory contra la propiedad")
if all_liens:
import pandas as pd
df_liens = pd.DataFrame(all_liens)
if not df_liens.empty:
cols_to_show = [c for c in ["holder", "type", "amount", "filed", "survives_foreclosure", "warning"] if c in df_liens.columns]
st.dataframe(df_liens[cols_to_show], use_container_width=True)
if total_surviving > 0:
st.error(
f"**Total surviving debt heredable post-foreclosure:** "
f"**${total_surviving:,.0f}**. Effective MAB = MAB original - ${total_surviving:,.0f}."
)
elif liens_status == "PENDING_V1_1":
st.warning(
"**Liens detail no disponible automáticamente** (Wave 1.5A v1.1 deferred a Phase 3.5)."
)
if investor_warning:
st.info(investor_warning)
st.markdown(f"**Fuentes:** {', '.join(court.get('sources_used') or [])}")
return # CONFIRMED es mas fuerte que CRITICAL, no mostrar el otro banner abajo
if status == "CRITICAL_RED_FLAG":
st.error(
f"**CRITICAL RED FLAG — PRECIO ANOMALO** · "
f"Listing ${listing:,} esta **{abs(signed):.0f}% {direction}** el market estimate."
)
with st.expander("Detalle de la alerta de precio (LEER ANTES DE PROCEDER)", expanded=True):
st.markdown(f"**Veredicto preliminar:** {recommendation}")
# Estimates disponibles
st.markdown("**Market estimates utilizados:**")
est_rows = []
label_map = {
"zillow_zestimate": "Zillow Zestimate",
"redfin_estimate": "Redfin Estimate",
"tax_implied_market": "Tax assessed → market (ratio 0.85)",
"comps_mid": "Comps mid (property_value)",
}
for k, lbl in label_map.items():
v = estimates.get(k)
if v:
disc = (listing - v) / v * 100
est_rows.append(f"- {lbl}: ${v:,} (listing {disc:+.0f}%)")
if est_rows:
st.markdown("\n".join(est_rows))
else:
st.caption("(estimates parciales — flag derivada de fuente limitada)")
st.markdown(f"**Fuentes:** {', '.join(sources) if sources else 'limitadas'}")
reasons = price_validation.get("possible_reasons") or []
if reasons:
st.markdown("---")
st.markdown("**Posibles causas del precio anomalo (no inventar, son las USA-tipicas):**")
for r in reasons:
st.markdown(f"- {r}")
investigation = price_validation.get("mandatory_investigation") or []
if investigation:
st.markdown("---")
st.markdown("**Due diligence OBLIGATORIA antes de oferta:**")
for i in investigation:
st.markdown(f"- {i}")
st.markdown("---")
st.info(
"**Importante:** los agentes (DealAnalyzer, Coordinator, ValueEstimator, "
"OfferStrategist, ContextualGlossaryAgent) ya recibieron este red flag y deben "
"mencionarlo en sus secciones. Si una metrica financiera parece 'demasiado buena' "
"(Cap Rate >12%, CoC >25%, DSCR >1.7), trata el numero como sospechoso hasta "
"completar la due diligence."
)
elif status == "WARNING":
st.warning(
f"**PRICE WARNING** · Listing ${listing:,} esta {abs(signed):.0f}% "
f"{direction} el market estimate. {recommendation}"
)
with st.expander("Detalle de la alerta", expanded=False):
est_rows = []
label_map = {
"zillow_zestimate": "Zillow Zestimate",
"redfin_estimate": "Redfin Estimate",
"tax_implied_market": "Tax assessed implied",
"comps_mid": "Comps mid",
}
for k, lbl in label_map.items():
v = estimates.get(k)
if v:
est_rows.append(f"- {lbl}: ${v:,}")
if est_rows:
st.markdown("\n".join(est_rows))
st.caption(f"Fuentes: {', '.join(sources) if sources else 'limitadas'}")
elif status == "NORMAL":
st.success(
f"Precio validado: listing ${listing:,} dentro de ±10% del market estimate "
f"(discrepancia {signed:+.1f}%)."
)
elif status == "UNKNOWN":
# Bug 6: UNKNOWN-pero-suspicious-low → render como WARNING amarillo, no info gris
if price_validation.get("suspicious_low_listing"):
recommendation = price_validation.get("recommendation", "")
reasons = price_validation.get("possible_reasons") or []
investigation = price_validation.get("mandatory_investigation") or []
rejected = price_validation.get("rejected_sources") or []
st.warning(
f"**HIPÓTESIS DISTRESSED DEAL** · Listing ${listing:,} es estadísticamente raro "
f"para SFR en Florida (<$150K). Sin fuentes confiables para validar, el sistema "
f"sugiere foreclosure / tax_deed / REO como hipótesis primaria."
)
with st.expander("Hipótesis distressed: causas más probables + due diligence", expanded=True):
st.markdown(f"**Veredicto preliminar:** {recommendation}")
if rejected:
st.caption("**Fuentes descartadas por baja calidad:**")
for r in rejected:
st.caption(f" - {r}")
if reasons:
st.markdown("**Hipótesis ordenadas por likelihood en Florida (<$150K SFR):**")
for r in reasons:
st.markdown(f"- {r}")
if investigation:
st.markdown("---")
st.markdown("**Due diligence OBLIGATORIA:**")
for i in investigation:
st.markdown(f"- {i}")
st.info(
"**Pregunta clave para el usuario:** ¿el deal_type ingresado ('MLS' por default) "
"es correcto? Si es foreclosure / tax_deed / REO, **cambiar el deal_type en el "
"formulario** y re-correr — el sistema usará la fórmula MAB en lugar de "
"Strike/Stretch/Walk-Away (números muy diferentes)."
)
else:
st.info(
"️ Price validation: **UNKNOWN** — no hay fuentes disponibles para validar el "
"precio contra el mercado. Activar `ENABLE_FIRECRAWL_PRICE_CHECK=true` en `.env` "
"para usar Zillow/Redfin (~6 credits/deal), o esperar a que el tax-assessed scraper "
"este disponible. Procede con cuidado y considera un lookup manual."
)
def _render_property_type_warning(ptw: dict, pipeline_mode: str):
"""Renderea alerta cuando detect_property_type_anomalies dispara warnings o el
pipeline corrio en modo land_simplified."""
if not ptw:
return
warnings = ptw.get("warnings") or []
suggested = ptw.get("suggested_type")
declared = ptw.get("declared_type")
is_mismatch = ptw.get("is_mismatch")
confidence = ptw.get("confidence")
rec = ptw.get("recommendation", "")
if pipeline_mode == "land_simplified":
st.error(
f"**PROPIEDAD TIPO TERRENO DETECTADA** · "
f"declarada como '{declared}', sistema detectó '{suggested}'. "
f"Pipeline SFR fue **bypassado** (DealAnalyzer / LenderMatcher / "
f"ValueEstimator / OfferStrategist NO se ejecutaron porque asumen SFR rentable)."
)
elif is_mismatch:
st.warning(
f"**PROPERTY TYPE MISMATCH** · declarada '{declared}', "
f"sistema sugiere '{suggested}' (confianza: {confidence}). "
f"Validar manualmente antes de tomar decisiones."
)
elif warnings:
st.info(
f"️ **Algunos indicadores atípicos** detectados ({len(warnings)} warning(s)). "
f"property_type declarado='{declared}' — verificar."
)
with st.expander("Detalle de detección de tipo de propiedad", expanded=(pipeline_mode == "land_simplified")):
st.markdown(f"**Recomendación:** {rec}")
st.markdown(f"**Property type declarado:** `{declared}`")
if suggested and suggested != declared:
st.markdown(f"**Property type sugerido:** `{suggested}` (confianza: `{confidence}`)")
st.markdown(f"**Pipeline mode:** `{pipeline_mode}`")
if warnings:
st.markdown("**Warnings que dispararon la alerta:**")
for w in warnings:
st.markdown(f"- {w}")
st.markdown(
f"**Scores internos:** land={ptw.get('score_land', 0)}, "
f"commercial={ptw.get('score_commercial', 0)}, "
f"mobile={ptw.get('score_mobile', 0)}"
)
if pipeline_mode == "land_simplified":
st.info(
"**Pipeline LAND-simplified** ejecutado:\n\n"
"- FloridaResearcher (LAND mode — zoning, construction costs)\n"
"- Coordinator (LAND mode — veredicto LAND-specific)\n"
"- ContextualGlossaryAgent (briefing LAND con alerta)\n"
"- DealAnalyzer SKIPPED (no aplica DSCR/Cap Rate a raw land)\n"
"- LenderMatcher SKIPPED (land loans son productos distintos)\n"
"- ValueEstimator SKIPPED (comparables SFR no aplican)\n"
"- OfferStrategist SKIPPED (Strike/MAB formulas no aplican)\n\n"
"Si esto NO es land y querés correr el pipeline SFR completo, "
"cambiá el property_type en el formulario y re-corré el análisis."
)
def _render_anomalies_alert(anomalies: dict):
"""Bug 3: Renderea alerta de metricas anomalas (Cap Rate >12%, CoC >25%, etc).
Distingue dos niveles:
- is_critical=True: st.error rojo con expander expandido (>=2 HIGH, >=1 HIGH +
2 MEDIUM, o >=3 MEDIUM apilados)
- has_anomalies=True (no critical): st.warning amarillo con expander colapsado
- has_anomalies=False: no renderea nada (silent)
"""
if not anomalies or not isinstance(anomalies, dict):
return
if not anomalies.get("has_anomalies"):
return
is_critical = anomalies.get("is_critical", False)
count = anomalies.get("anomaly_count", 0)
high = anomalies.get("high_severity_count", 0)
medium = anomalies.get("medium_severity_count", 0)
recommendation = anomalies.get("recommendation", "")
flagged = anomalies.get("flagged_metrics", [])
if is_critical:
st.error(
f"**MÉTRICAS ANOMALAS — Deal 'too good to be true'** · "
f"{count} flag(s) ({high} HIGH, {medium} MEDIUM). Las métricas superan los "
f"benchmarks USA típicos — VALIDAR INPUTS antes de proceder."
)
expanded = True
else:
st.warning(
f"**{count} métrica(s) sobre benchmark USA típico** "
f"({high} HIGH, {medium} MEDIUM). Revisar antes de proceder."
)
expanded = False
with st.expander("Detalle de métricas anómalas + benchmarks USA", expanded=expanded):
st.markdown(f"**Recomendación del sistema:** {recommendation}")
st.markdown("---")
st.markdown("**Métricas flageadas vs benchmarks reales:**")
# Tabla
rows_md = ["| Escenario | Métrica | Valor | Umbral | Típico USA | Severidad |",
"|---|---|---|---|---|---|"]
for f in flagged:
val = f["value"]
if isinstance(val, float) and abs(val) < 100:
val_str = f"{val:.2f}"
else:
val_str = f"{val:,.0f}"
sev_emoji = "" if f["severity"] == "HIGH" else ""
rows_md.append(
f"| {f['scenario']} | {f['metric']} | **{val_str}** | "
f"> {f['threshold']} | {f['typical']} | {sev_emoji} {f['severity']} |"
)
st.markdown("\n".join(rows_md))
st.markdown("---")
st.markdown("**Posibles causas (en orden de frecuencia USA real estate):**")
st.markdown(
"1. **Data error en inputs** — rent inflada en MLS, price equivocado, "
"ARV optimista, rehab subestimado. Cross-check con Zillow/RentCast/comps recientes.\n"
"2. **Hidden problem heredable** — tax delinquency, IRS lien, code violations, "
"foreclosure en curso, title issues, damage no fotografiado. Court records + "
"code enforcement + title search ANTES de oferta.\n"
"3. **Deal real excepcional** — off-market motivado, divorce/probate, distressed "
"seller con timeline cerrado. Raro pero existe. Requiere validación exhaustiva."
)
st.info(
"Los agentes (DealAnalyzer, Coordinator, ContextualGlossaryAgent) ya recibieron "
"estos flags y deben incluir una sección **'Validación de Inputs Requerida'** "
"en sus outputs. Si no aparece, el modelo se la salteó — re-correr o auditar."
)
def _render_briefing_section(briefing_out: Optional[str], briefing_meta: dict):
"""Briefing Ejecutivo prominente arriba. Si no hay briefing (analysis vieja
pre-ContextualGlossaryAgent), se omite gracefully."""
if not briefing_out:
st.info(
"**Briefing Ejecutivo no disponible** — esta análisis fue corrida "
"antes de que existiera el ContextualGlossaryAgent. El Veredicto Coordinador "
"abajo tiene la info principal."
)
return
err = briefing_meta.get("error") if briefing_meta else None
sec = briefing_meta.get("seconds", 0) if briefing_meta else 0
tok = briefing_meta.get("tokens", 0) if briefing_meta else 0
if err:
st.error(f"ContextualGlossaryAgent falló: {err}. Mostrando markdown crudo del veredicto abajo.")
return
# Render del briefing markdown
st.markdown(briefing_out)
st.caption(f"_Briefing generado por ContextualGlossaryAgent · {sec:.0f}s · {tok} tokens_")
def _render_agent_block_inline(agent: dict):
"""Variante de _render_agent_block sin expander (ya estamos dentro de uno)."""
err = agent.get("error")
sec = agent.get("seconds", 0)
tok = agent.get("tokens", 0)
output = agent.get("output", "")
if err:
st.error(f"Error: {err}")
if output:
st.markdown(output)
else:
st.caption("(sin output)")
st.caption(f"_{sec:.0f}s · {tok} tokens_")
def _render_verdict_section(final_out: str, deal_d: dict, result: dict):
parsed = parse_verdict(final_out)
if parsed:
verdict, score = parsed
emoji, _color, label = VERDICT_BADGE[verdict]
st.subheader(f"{emoji} Veredicto final: **{label}** · Score **{score}/10**")
st.progress(score / 10)
else:
st.subheader("Veredicto final (formato no parseado)")
st.caption("El LLM se desvió del formato esperado. Mostrando markdown crudo abajo.")
# Botones de acción
c1, c2, c3, c4 = st.columns(4)
with c1:
if st.button("Guardar análisis", use_container_width=True):
# El orchestrator ya lo guardó automáticamente. Confirmar.
ts = result.get("started_at", "")
try:
stamp = datetime.fromisoformat(ts).strftime("%Y%m%d_%H%M%S")
except Exception:
stamp = "?"
st.toast(f"Guardado en analyses/ (timestamp {stamp})", icon="")
with c2:
# TODO Phase 3+: implementar export real a PDF con reportlab o weasyprint.
if st.button("Exportar PDF", use_container_width=True, help="Placeholder — implementar en Phase 3+"):
st.toast("Export PDF: pendiente.", icon="")
with c3:
# Mailto link con resumen ejecutivo
subject = f"Análisis AR-House: {deal_d.get('address', '(sin dirección)')}"
body_lines = [
"Análisis generado por AR-House:",
"",
f"Propiedad: {deal_d.get('address', '')}",
f"Precio: ${deal_d.get('price', 0):,.0f}",
"",
"--- Veredicto ---",
final_out[:1500],
"",
"(generado localmente, este link no envía nada — solo abre tu cliente de mail)",
]
body = "\n".join(body_lines)
mailto = "mailto:?subject=" + urllib.parse.quote(subject) + "&body=" + urllib.parse.quote(body)
st.link_button("Compartir por email", mailto, use_container_width=True)
with c4:
st.metric("Tiempo total", f"{result['total_seconds']:.0f}s")
# Markdown crudo del veredicto (siempre lo mostramos abajo)
with st.expander("Veredicto completo (markdown)", expanded=True):
st.markdown(final_out)
def _render_financial_section(deal_an_out: str):
st.subheader("Análisis financiero")
metrics = parse_financial_metrics(deal_an_out)
if metrics:
cols = st.columns(len(metrics))
for col, (label, value) in zip(cols, metrics.items()):
with col:
st.metric(label, value)
else:
st.caption("No pude parsear las métricas. Mostrando markdown crudo abajo.")
with st.expander("Reporte completo del DealAnalyzer", expanded=True):
st.markdown(deal_an_out)
def _render_agent_block(title: str, agent: dict):
"""Render generico de un bloque de agente."""
err = agent.get("error")
sec = agent.get("seconds", 0)
tok = agent.get("tokens", 0)
output = agent.get("output", "")
header = f"{title} · {sec:.0f}s · {tok} tokens"
if err:
header += " · ERROR"
with st.expander(header, expanded=False):
if err:
st.error(f"Error: {err}")
if output:
st.markdown(output)
else:
st.caption("(sin output)")
def _render_email_section(deal_d: dict, result: dict):
st.subheader("Generar comunicación al vendedor")
with st.expander("Abrir generador de emails", expanded=st.session_state.show_email_section):
st.session_state.show_email_section = True # se queda abierto despues del primer click
c1, c2 = st.columns([2, 1])
with c1:
email_type = st.selectbox(
"Tipo de email",
options=list(EMAIL_TYPES.keys()),
format_func=lambda k: f"{k}. {EMAIL_TYPES[k]}",
)
with c2:
language = st.selectbox("Idioma", ["english", "spanish"])
urgency = st.selectbox("Urgencia", ["low", "medium", "high"], index=1)
c1, c2, c3 = st.columns(3)
with c1:
recipient_name = st.text_input("Nombre del recipient", "")
with c2:
recipient_company = st.text_input("Empresa", "")
with c3:
recipient_role = st.text_input("Rol", "")
if st.button("Generar email", type="primary"):
# Reconstruir DealInputs y BuyerProfile desde el dict del análisis
try:
deal = DealInputs(**{k: v for k, v in deal_d.items() if k in DealInputs.__annotations__})
profile_d = result["profile"]
profile = BuyerProfile(**{k: v for k, v in profile_d.items() if k in BuyerProfile.__annotations__})
recipient = {
"name": recipient_name.strip() or "(no provisto)",
"company": recipient_company.strip() or "(no provisto)",
"role": recipient_role.strip() or "(no provisto)",
}
# last_analysis puede ser dataclass o dict; compose_email lo acepta opcional
last_a = st.session_state.last_analysis
with st.spinner(f"Generando email tipo {email_type}..."):
def cb(msg):
pass # silencioso para EmailComposer (es rapido)
email_result = compose_email(
email_type=email_type,
deal=deal,
profile=profile,
last_analysis=last_a,
recipient=recipient,
language=language,
urgency=urgency,
status_cb=cb,
)
st.session_state.last_email_output = email_result.output
except Exception as e:
st.error(f"Error generando email: {e}")
st.exception(e)
if st.session_state.last_email_output:
st.divider()
st.markdown("**Email generado:**")
st.markdown(st.session_state.last_email_output)
# Copy helper: text_area con el contenido para que el user lo copie con Ctrl+A, Ctrl+C
st.text_area(
"Texto plano (Ctrl+A → Ctrl+C para copiar)",
value=st.session_state.last_email_output,
height=200,
)
# ════════════════════════════════════════════════════════════════════════════
# Page: Historico
# ════════════════════════════════════════════════════════════════════════════
def page_inventory():
"""Inventario — full view de TODOS los deals en deals.db.
Cards como vista primaria (con botones Source / Visto / Interesante / Pasar
/ Analizar). Tabla compacta movida a expander secundario.
Paginación: 20 deals por página para no renderear 161 cards de una.
"""
from deals_db import init_db, list_deals
from property_type_inference import label_for as property_type_label
from search_engine import _is_auction_dead
init_db()
st.title("Inventario de oportunidades")
st.caption(
"Vista completa de la base. Cada card tiene acciones: ver source, marcar "
"visto/interesante/pasar, o **Analizar a fondo** (precarga el formulario "
"de Análisis manual)."
)
# Pull all deals (limit alto)
all_deals = list_deals(limit=5000)
if not all_deals:
st.info(
"La base está vacía. Andá a **Buscar deals** para correr scrapers "
"y poblar el inventario."
)
return
# ─── Top-level metrics ──────────────────────────────────────────────────
total = len(all_deals)
dead_count = sum(1 for d in all_deals if _is_auction_dead(d.get("auction_status")))
live_count = total - dead_count
sources_set = set(d.get("source") for d in all_deals if d.get("source"))
counties_set = set(d.get("county") for d in all_deals if d.get("county"))
c1, c2, c3, c4 = st.columns(4)
c1.metric("Total deals", f"{total:,}")
c2.metric("Live (activos)", f"{live_count:,}", help="Excluye REDEEMED/CANCELED/SOLD")
c3.metric("Fuentes únicas", len(sources_set))
c4.metric("Condados únicos", len(counties_set))
st.divider()
# ─── Filters bar ────────────────────────────────────────────────────────
fc1, fc2, fc3, fc4 = st.columns([3, 2, 2, 2])
with fc1:
search_term = st.text_input(
"Buscar (address / case# / parcel / city / zip)",
placeholder="Ej: Jacksonville, 091-, BLACK BUCK, 33012",
key="inv_search",
)
with fc2:
sources_sorted = ["(todas)"] + sorted(sources_set)
source_filter = st.selectbox("Source", options=sources_sorted, index=0, key="inv_source")
with fc3:
counties_sorted = ["(todos)"] + sorted(c for c in counties_set if c)
county_filter = st.selectbox("Condado", options=counties_sorted, index=0, key="inv_county")
with fc4:
sort_option = st.selectbox(
"Ordenar por",
options=["Score (desc)", "Más reciente", "Precio (asc)", "Precio (desc)"],
index=0,
key="inv_sort",
)
cfa, cfb, cfc, cfd = st.columns([3, 2, 2, 2])
with cfa:
# Property type filter (multi-select)
ptypes_set = set(d.get("property_type") for d in all_deals if d.get("property_type"))
ptype_options = sorted(ptypes_set)
if ptype_options:
from property_type_inference import label_for as property_type_label_fn
selected_ptypes = st.multiselect(
"Tipo de propiedad",
options=ptype_options,
default=[],
format_func=lambda x: property_type_label_fn(x),
key="inv_ptype",
help="Vacío = todos. Multi-select: marcá los que quieras ver.",
)
else:
selected_ptypes = []
with cfb:
include_dead = st.checkbox(
"Incluir REDEEMED/CANCELED", value=False, key="inv_include_dead",
help="Por default oculta cases inactivos",
)
with cfc:
only_classified = st.checkbox(
"Solo clasificados", value=False, key="inv_only_classified",
help="Mostrar solo deals con score asignado",
)
with cfd:
only_no_hoa = st.checkbox(
"Solo sin HOA", value=False, key="inv_only_no_hoa",
help="Muestra solo propiedades con hoa_monthly=0 (confirmadas sin HOA). "
"NULL=desconocido y >0=con HOA se filtran.",
)
cfe, cff = st.columns([3, 4])
with cfe:
only_lender_owned = st.checkbox(
"Solo lender-owned (REO opportunity)", value=False, key="inv_only_lender",
help="Filtra propiedades cuyo owner_name matchea lender patterns "
"(BANK_NATIONAL, GSE_FEDERAL, MBS_TRUSTEE, SERVICER). "
"Owner data viene de pre-screening previo o del PA scraper. "
"Si el deal aun no fue pre-screened, NO aparece en este filtro.",
)
# ─── Apply filters in-memory ────────────────────────────────────────────
filtered = list(all_deals)
if not include_dead:
filtered = [d for d in filtered if not _is_auction_dead(d.get("auction_status"))]
if only_classified:
filtered = [d for d in filtered if d.get("classification_score") is not None]
if source_filter != "(todas)":
filtered = [d for d in filtered if d.get("source") == source_filter]
if county_filter != "(todos)":
filtered = [d for d in filtered if d.get("county") == county_filter]
if selected_ptypes:
filtered = [d for d in filtered if d.get("property_type") in selected_ptypes]
if only_no_hoa:
# hoa_monthly == 0 = confirmed sin HOA. NULL/None = unknown → excluido por seguridad.
filtered = [d for d in filtered if (d.get("hoa_monthly") is not None and float(d.get("hoa_monthly") or 0) == 0)]
if only_lender_owned:
# Filter by owner_name in known lender patterns. Quick text-based check
# to avoid running owner_classifier on every deal in inventory.
_LENDER_KEYWORDS_QUICK = [
"BANK OF AMERICA", "WELLS FARGO", "JPMORGAN", "CHASE", "U.S. BANK",
"US BANK", "PNC BANK", "CITIBANK", "TRUIST",
"FREDDIE MAC", "FANNIE MAE", "FEDERAL HOME LOAN",
"FEDERAL NATIONAL MORTGAGE", "SECRETARY OF HUD",
"DEUTSCHE BANK", "BANK OF NEW YORK", "U.S. BANK TRUST",
"WILMINGTON TRUST", "WILMINGTON SAVINGS",
"BAYVIEW LOAN", "SHELLPOINT", "NEWREZ", "MR. COOPER", "OCWEN",
"PENNYMAC", "NA TRSTEE", " TRUSTEE", "TR FOR",
]
def _is_lender_owned(deal):
# Try multiple owner sources
owner = (deal.get("owner_name") or deal.get("listing_description") or "").upper()
return any(kw in owner for kw in _LENDER_KEYWORDS_QUICK)
filtered = [d for d in filtered if _is_lender_owned(d)]
if search_term:
term = search_term.strip().lower()
def _hay(d):
return any(
term in (str(d.get(k) or "")).lower()
for k in ("address", "case_number", "parcel_id", "city", "zip")
)
filtered = [d for d in filtered if _hay(d)]
# Sorting
if sort_option == "Score (desc)":
filtered.sort(key=lambda d: (d.get("classification_score") or 0,
d.get("scraped_at") or ""), reverse=True)
elif sort_option == "Más reciente":
filtered.sort(key=lambda d: d.get("scraped_at") or "", reverse=True)
elif sort_option == "Precio (asc)":
filtered.sort(key=lambda d: (d.get("listing_price") is None, d.get("listing_price") or 0))
elif sort_option == "Precio (desc)":
filtered.sort(key=lambda d: d.get("listing_price") or 0, reverse=True)
if not filtered:
st.info("No hay deals que coincidan con esos filtros.")
return
# ─── Pagination ─────────────────────────────────────────────────────────
PAGE_SIZE = 20
n_pages = max(1, (len(filtered) + PAGE_SIZE - 1) // PAGE_SIZE)
# Reset page if filters change → keep page in session_state
new_signature = (search_term, source_filter, county_filter, sort_option,
include_dead, only_classified, tuple(selected_ptypes))
if st.session_state.get("inv_filter_signature") != new_signature:
st.session_state["inv_page_num"] = 1
st.session_state["inv_filter_signature"] = new_signature
current_page = st.session_state.get("inv_page_num", 1)
current_page = max(1, min(current_page, n_pages))
# BUG FIX: removed jump-to-page number_input. Tenia key="inv_jump" que
# mantenia su valor anterior en session_state, sobreescribiendo el
# current_page seteado por Anterior/Siguiente. Resultado: click "Siguiente"
# → handler setea inv_page_num=2 → en el rerun, jump widget aun tiene valor
# 1 en session_state → if jump != current_page firea → reseteo a pagina 1.
# Anterior/Siguiente + page counter es suficiente para navegacion.
pag_a, pag_b, pag_c = st.columns([1, 3, 1])
with pag_a:
if st.button("Anterior", disabled=current_page <= 1, key="inv_prev",
use_container_width=True):
st.session_state["inv_page_num"] = current_page - 1
st.rerun()
with pag_b:
st.markdown(
f"<div style='text-align:center;padding-top:6px;'>Página "
f"<b>{current_page}</b> de <b>{n_pages}</b> · "
f"<i>{len(filtered):,}</i> deals tras filtros</div>",
unsafe_allow_html=True,
)
with pag_c:
if st.button("Siguiente", disabled=current_page >= n_pages, key="inv_next",
use_container_width=True):
st.session_state["inv_page_num"] = current_page + 1
st.rerun()
# ─── Compact table (secondary view, hidden in expander) ─────────────────
with st.expander(f"Ver tabla compacta de los {len(filtered):,} deals filtrados",
expanded=False):
import pandas as pd
rows = []
for d in filtered:
rows.append({
"ID": d.get("id"),
"Source": d.get("source", "?"),
"Condado": d.get("county", "?"),
"Tipo": property_type_label(d.get("property_type") or "unknown"),
"Deal": d.get("deal_type", "?"),
"Address": (d.get("address") or "")[:60],
"Precio": d.get("listing_price"),
"Score": d.get("classification_score"),
"Auction": d.get("auction_status") or "—",
"Case#": d.get("case_number") or "",
"Scrapeado": (d.get("scraped_at") or "")[:10],
})
df = pd.DataFrame(rows)
st.dataframe(
df,
use_container_width=True,
hide_index=True,
column_config={
"ID": st.column_config.NumberColumn("ID", width="small"),
"Precio": st.column_config.NumberColumn("Precio", format="$%d"),
"Score": st.column_config.NumberColumn("Score", width="small"),
},
height=400,
)
st.divider()
# ─── Cards (PRIMARIO) — paginated ──────────────────────────────────────
start_idx = (current_page - 1) * PAGE_SIZE
end_idx = start_idx + PAGE_SIZE
page_deals = filtered[start_idx:end_idx]
st.subheader(f"Deals {start_idx + 1}{min(end_idx, len(filtered))} de {len(filtered)}")
for d in page_deals:
_render_deal_card(d)
# Bottom pagination repeat (UX: no scroll back to top)
if n_pages > 1:
st.divider()
bp_a, bp_b, bp_c = st.columns([1, 2, 1])
with bp_a:
if st.button("Anterior", disabled=current_page <= 1, key="inv_prev_bot",
use_container_width=True):
st.session_state["inv_page_num"] = current_page - 1
st.rerun()
with bp_b:
st.markdown(
f"<div style='text-align:center;padding-top:6px;'>Página "
f"<b>{current_page}</b> de <b>{n_pages}</b></div>",
unsafe_allow_html=True,
)
with bp_c:
if st.button("Siguiente", disabled=current_page >= n_pages, key="inv_next_bot",
use_container_width=True):
st.session_state["inv_page_num"] = current_page + 1
st.rerun()
def page_favorites():
"""Favoritos — lista deals marcados como 'interesting'.
El usuario marca/desmarca con el botón Interesante / Quitar favorito
en cualquier card (Inventario, Search, o acá mismo).
"""
from deals_db import init_db, list_deals
from property_type_inference import label_for as property_type_label
init_db()
st.title("Favoritos")
st.caption(
"Deals que marcaste con Interesante. Para sacar uno de acá, "
"clickeá Quitar favorito en su card."
)
favorites = list_deals(status=config.DEAL_STATUS_INTERESTING, limit=1000)
if not favorites:
st.info(
"Todavía no tenés favoritos. Andá a **Inventario** o **Buscar deals**, "
"encontrá deals que te interesen y clickeá **Interesante** en la card."
)
return
# ─── Top metrics ────────────────────────────────────────────────────────
c1, c2, c3, c4 = st.columns(4)
c1.metric("Total favoritos", len(favorites))
# Average score (de los que tienen)
scored = [d.get("classification_score") for d in favorites if d.get("classification_score") is not None]
c2.metric("Score promedio", f"{sum(scored)/len(scored):.0f}/100" if scored else "—",
help="Promedio del DealClassifier sobre favoritos con score")
# Sum of listing_prices (solo los que tienen precio)
priced = [d.get("listing_price") for d in favorites if d.get("listing_price")]
c3.metric("Valor total", fmt_money(sum(priced)) if priced else "—",
help="Suma de listing_price de favoritos con precio definido")
# Counties con favoritos
counties = set(d.get("county") for d in favorites if d.get("county"))
c4.metric("Condados", len(counties))
st.divider()
# ─── Filters bar ────────────────────────────────────────────────────────
fc1, fc2, fc3 = st.columns([3, 2, 2])
with fc1:
search_term = st.text_input(
"Filtrar (address / case# / parcel / city)",
placeholder="Ej: Jacksonville, 091-",
key="fav_search",
)
with fc2:
sources_in_fav = sorted({d.get("source") for d in favorites if d.get("source")})
source_filter = st.selectbox("Source", options=["(todas)"] + sources_in_fav,
index=0, key="fav_source")
with fc3:
sort_option = st.selectbox(
"Ordenar por",
options=["Score (desc)", "Marcado recientemente", "Precio (asc)", "Precio (desc)"],
index=0, key="fav_sort",
)
# ─── Apply filters ──────────────────────────────────────────────────────
filtered = list(favorites)
if source_filter != "(todas)":
filtered = [d for d in filtered if d.get("source") == source_filter]
if search_term:
term = search_term.strip().lower()
def _hay(d):
return any(
term in (str(d.get(k) or "")).lower()
for k in ("address", "case_number", "parcel_id", "city", "zip")
)
filtered = [d for d in filtered if _hay(d)]
# Sort
if sort_option == "Score (desc)":
filtered.sort(key=lambda d: (d.get("classification_score") or 0,
d.get("last_seen_at") or ""), reverse=True)
elif sort_option == "Marcado recientemente":
# Usamos last_seen_at como proxy (no hay un favorited_at todavia)
filtered.sort(key=lambda d: d.get("last_seen_at") or "", reverse=True)
elif sort_option == "Precio (asc)":
filtered.sort(key=lambda d: (d.get("listing_price") is None, d.get("listing_price") or 0))
elif sort_option == "Precio (desc)":
filtered.sort(key=lambda d: d.get("listing_price") or 0, reverse=True)
if not filtered:
st.info(f"De tus {len(favorites)} favoritos, ninguno coincide con esos filtros.")
return
st.caption(f"Mostrando **{len(filtered)}** de {len(favorites)} favoritos")
st.divider()
# ─── Cards (no pagination needed — favoritos rara vez >50) ─────────────
for d in filtered:
_render_deal_card(d)
def page_history():
st.title("Histórico de análisis")
files = list_history_files()
if not files:
st.info("No hay análisis guardados todavía. Corré uno desde **Análisis manual**.")
return
st.caption(f"{len(files)} análisis guardados en `{config.ANALYSES_DIR}/`")
# Lista de análisis con metadata extraída
rows = []
for fp in files:
try:
data = load_analysis_json(fp)
deal = data.get("deal", {})
verdict_parsed = parse_verdict(data.get("final", {}).get("output", ""))
verdict_str = f"{verdict_parsed[0]} ({verdict_parsed[1]}/10)" if verdict_parsed else "—"
rows.append({
"file": fp,
"name": fp.name,
"address": deal.get("address", "—"),
"price": deal.get("price"),
"verdict": verdict_str,
"seconds": data.get("total_seconds", 0),
"started_at": data.get("started_at", ""),
})
except Exception as e:
rows.append({"file": fp, "name": fp.name, "error": str(e)})
# Selectbox para elegir uno
selected_idx = st.selectbox(
"Elegí un análisis para ver:",
options=list(range(len(rows))),
format_func=lambda i: (
f"{rows[i].get('started_at', '?')[:19]} · "
f"{rows[i].get('address', '(error)')} · "
f"{rows[i].get('verdict', '—')} · "
f"{fmt_money(rows[i].get('price'))}"
),
)
selected = rows[selected_idx]
if "error" in selected:
st.error(f"No se pudo leer el archivo: {selected['error']}")
return
# Cargar y mostrar
if st.button("Cargar análisis", type="primary"):
data = load_analysis_json(selected["file"])
st.session_state.last_analysis = data
st.toast(f"Cargado: {selected['name']}", icon="")
# Mostrar acá mismo
st.divider()
_render_results()
# ════════════════════════════════════════════════════════════════════════════
# Pages placeholder: Feed + Mercados (Phase 3E)
# ════════════════════════════════════════════════════════════════════════════
def page_search_deals():
"""Buscar Deals — Phase 3B4 on-demand search UI.
Workflow:
1. Select counties (FL Primary by default)
2. Select sources (filtered by compatible with selected counties)
3. Optional filters (price/beds/deal_type)
4. Preflight: estimate credits + show budget warning
5. Submit → run_search() → display results below
"""
st.title("Búsqueda de propiedades")
st.caption(
"Selecciona condados + fuentes y el sistema scrapea EN ESE MOMENTO. "
"No hay scrape automático nocturno — vos controlás cuándo y dónde."
)
from scrapers.registry import list_sources, get_sources_for_county, estimate_credits
from search_engine import preflight_check, run_search, search_existing_only
# ─── Cargar markets database ───────────────────────────────────────────
try:
with open(config.MARKETS_DB_FILE, encoding="utf-8") as f:
markets_db = json.load(f)
except Exception as e:
st.error(f"No se pudo cargar markets_database.json: {e}")
return
# Build flat list of (region, "County (STATE)") tuples
county_options: list[str] = []
county_to_meta: dict[str, dict] = {}
for region, mkts in markets_db.get("regions", {}).items():
if not isinstance(mkts, list):
continue
for m in mkts:
county = m.get("county") or m.get("name")
state = m.get("state", "?")
if not county:
continue
label = f"{county} ({state}) · {region}"
county_options.append(label)
county_to_meta[label] = {
"county": county,
"state": state,
"region": region,
**m,
}
# ─── Section 1: County selection ───────────────────────────────────────
st.subheader("1. Condados")
presets = markets_db.get("presets") or {}
preset_names = ["(custom)"] + list(presets.keys())
selected_preset = st.selectbox(
"Preset",
options=preset_names,
index=0,
help="Atajos de selección preconfigurados desde markets_database.json. "
"'(custom)' = elegí condados manualmente abajo.",
)
# Preset application logic
default_counties = []
if selected_preset != "(custom)":
preset_counties = presets.get(selected_preset, [])
if isinstance(preset_counties, list):
# match by county name + state combination
for pc in preset_counties:
pc_county = pc.get("county") if isinstance(pc, dict) else None
pc_state = pc.get("state") if isinstance(pc, dict) else None
for lbl, meta in county_to_meta.items():
if meta["county"] == pc_county and meta["state"] == pc_state:
default_counties.append(lbl)
break
selected_county_labels = st.multiselect(
"Condados a buscar",
options=county_options,
default=default_counties,
help="Multi-select. Cuantos más condados, más tiempo demora la búsqueda.",
)
selected_counties = [county_to_meta[lbl]["county"] for lbl in selected_county_labels]
selected_states = list({county_to_meta[lbl]["state"] for lbl in selected_county_labels})
if not selected_counties:
st.info("Selecciona al menos un condado para continuar.")
return
st.caption(f"{len(selected_counties)} condado(s) seleccionado(s): "
f"`{', '.join(selected_counties[:5])}{' ...' if len(selected_counties) > 5 else ''}`")
# ─── Section 2: Source selection ───────────────────────────────────────
st.subheader("2. Fuentes")
# Filter sources to those compatible with at least one selected county
all_sources = list_sources()
compatible_source_ids: set[str] = set()
for county in selected_counties:
for src in get_sources_for_county(county):
compatible_source_ids.add(src["id"])
if not compatible_source_ids:
st.warning(
"Ninguna fuente del registry soporta los condados seleccionados. "
"Aún no implementamos scrapers específicos para estos. "
"Considerá HUD Homestore (nacional) o esperá a que se implemente el county clerk."
)
return
source_options: list[tuple] = []
for src in all_sources:
if src["id"] in compatible_source_ids:
free_str = "GRATIS" if src["free"] else f"~{src['firecrawl_credits_per_run']} cr/run"
label = f"{src['label']}{free_str}{', '.join(src['deal_types_produced'])}"
source_options.append((src["id"], label, src))
# Render checkboxes per source
selected_source_ids: list[str] = []
for src_id, label, src in source_options:
default_checked = src["free"] # default: only free sources
if st.checkbox(label, value=default_checked, key=f"src_chk_{src_id}"):
selected_source_ids.append(src_id)
st.caption(f" ↳ {src.get('description', '')}")
if not selected_source_ids:
st.info("Selecciona al menos una fuente.")
return
# ─── Section 3: Optional filters ───────────────────────────────────────
st.subheader("3. Filtros opcionales")
c1, c2, c3 = st.columns(3)
with c1:
min_price = st.number_input(
"Precio mínimo (USD)", min_value=0, max_value=10_000_000, value=0, step=10_000,
)
max_price = st.number_input(
"Precio máximo (USD, 0 = sin límite)", min_value=0, max_value=10_000_000,
value=0, step=10_000,
)
with c2:
beds_min = st.number_input("Beds mínimo", min_value=0, max_value=10, value=0, step=1)
deal_type_options = ["foreclosure", "tax_deed", "reo", "mls", "off_market"]
selected_deal_types = st.multiselect(
"Deal types (vacío = todos)", options=deal_type_options, default=[],
)
with c3:
from deals_db import get_cities_for_counties
available_cities = get_cities_for_counties(selected_counties)
city_filter = st.multiselect(
"Ciudades (vacío = todas)",
options=available_cities,
default=[],
help="Filtra por una o más ciudades dentro de los condados seleccionados.",
placeholder="Todas las ciudades" if available_cities else "Sin datos aún",
)
class_options = ["potential_winner", "maybe", "pass", "red_flag"]
selected_classifications = st.multiselect(
"Classification (vacío = todos)", options=class_options, default=[],
)
filters = {}
if min_price > 0:
filters["min_price"] = min_price
if max_price > 0:
filters["max_price"] = max_price
if beds_min > 0:
filters["beds_min"] = beds_min
if selected_deal_types:
filters["deal_types"] = selected_deal_types
if selected_classifications:
filters["classifications"] = selected_classifications
if city_filter:
filters["cities"] = city_filter
# ─── Section 4: Preflight + actions ────────────────────────────────────
st.subheader("4. Verificación previa y ejecución")
preflight = preflight_check(selected_source_ids)
budget = preflight["budget_snapshot"]
c1, c2, c3 = st.columns(3)
with c1:
st.metric("Credits estimados", preflight["total_credits_estimated"],
help="Firecrawl credits que consumiria esta búsqueda")
with c2:
st.metric(
"Budget usado (mes)",
f"{budget['credits_used']}/{budget['credits_budget']}",
f"{budget['usage_pct']}%",
)
with c3:
post_pct = preflight["post_run_pct"]
st.metric(
"Post-run %",
f"{post_pct}%",
f"+{post_pct - budget['usage_pct']:.1f} pp",
delta_color="inverse",
)
# Warnings
for w in preflight["warnings"]:
if "" in w:
st.error(w)
elif "" in w:
st.warning(w)
else:
st.info(w)
can_run = preflight["ok_to_run"]
# Two action buttons: browse existing OR scrape fresh
c1, c2 = st.columns(2)
with c1:
browse_clicked = st.button(
"Sólo ver deals ya guardados (no scrape)",
help="Consulta deals.db existente sin re-scrapear. Instantáneo, gratis.",
use_container_width=True,
)
with c2:
run_clicked = st.button(
"Scrapear ahora (fresh fetch)",
type="primary",
disabled=not can_run,
help="Ejecuta los scrapers en vivo. Puede demorar 1-5 min por source.",
use_container_width=True,
)
# ─── Section 5: Action handlers ────────────────────────────────────────
if browse_clicked:
with st.spinner("Querying deals.db..."):
results = search_existing_only(
counties=selected_counties,
source_ids=selected_source_ids,
filters=filters,
)
st.session_state["search_results"] = {
"mode": "existing",
"counties": selected_counties,
"source_ids": selected_source_ids,
"filters": filters,
"deals": results,
}
st.success(f"{len(results)} deals encontrados en la base existente.")
if run_clicked:
# Reset cancel flag at start of fresh search
st.session_state["search_cancel_requested"] = False
# Persistent banner: tells the user how to abort.
st.warning(
"**Para cancelar:** click el botón **Stop** (cuadrado rojo) arriba a la "
"derecha de la página. La cancelación se aplica entre fuentes — si una "
"fuente está en curso, espera que termine esa antes de saltear las restantes."
)
with st.status("Ejecutando scrapers en vivo...", expanded=True) as status:
def cb(msg):
status.write(msg)
def _cancel_check():
return st.session_state.get("search_cancel_requested", False)
try:
result = run_search(
source_ids=selected_source_ids,
counties=selected_counties,
filters=filters,
status_cb=cb,
cancel_check=_cancel_check,
)
was_cancelled = result.get("cancelled", False)
state_str = "error" if was_cancelled else "complete"
label = (
f"Cancelado · {result['new_deals_count']} deals capturados antes de abortar"
if was_cancelled
else f"{result['matching_total']} deals matched ({result['new_deals_count']} nuevos)"
)
status.update(label=label, state=state_str)
st.session_state["search_results"] = {
"mode": "fresh",
"counties": selected_counties,
"source_ids": selected_source_ids,
"filters": filters,
"deals": result["matching_deals"],
"runs": result["runs"],
"elapsed_seconds": result["elapsed_seconds"],
"credits_used": result["total_credits_used"],
"errors": result["errors"],
"cancelled": was_cancelled,
}
except KeyboardInterrupt:
# User hit Streamlit Stop button — graceful abort
status.update(label="Búsqueda cancelada por el usuario (Stop)", state="error")
st.session_state["search_results"] = {
"mode": "fresh",
"counties": selected_counties,
"source_ids": selected_source_ids,
"filters": filters,
"deals": [],
"errors": ["Cancelled by user (Stop button)"],
"cancelled": True,
}
except Exception as e:
status.update(label=f"Error: {e}", state="error")
# ─── Section 6: Display results (if any) ───────────────────────────────
if "search_results" in st.session_state:
st.divider()
_render_search_results(st.session_state["search_results"])
def _render_search_results(search_results: dict):
"""Render the deals returned by run_search() / search_existing_only()."""
deals = search_results.get("deals", [])
mode = search_results.get("mode", "?")
st.subheader(f"Resultados ({len(deals)} deals)")
if mode == "fresh":
c1, c2, c3 = st.columns(3)
with c1:
st.metric("New en este run", search_results.get("runs", [{}])[0].get("deals_new", "?") if search_results.get("runs") else "?")
with c2:
st.metric("Tiempo de scrape", f"{search_results.get('elapsed_seconds', 0):.0f}s")
with c3:
st.metric("Credits usados", search_results.get("credits_used", 0))
# Show per-source results
with st.expander("Runs per source", expanded=False):
for r in search_results.get("runs", []):
st.markdown(f"**{r.get('source_label', r.get('source_id', '?'))}**")
st.json({
"deals_found": r.get("deals_found"),
"deals_new": r.get("deals_new"),
"deals_updated": r.get("deals_updated"),
"deals_classified": r.get("deals_classified"),
"errors_count": r.get("errors_count"),
})
if search_results.get("errors"):
st.error(f"{len(search_results['errors'])} error(s) durante scrape:")
for e in search_results["errors"][:5]:
st.caption(f" • {e}")
if not deals:
st.info("No se encontraron deals con los filtros actuales. Ajustá filtros o ejecutá un fresh scrape.")
return
# Sort / filter controls
c1, c2, c3 = st.columns([2, 2, 2])
with c1:
sort_by = st.selectbox(
"Ordenar por",
options=["score_desc", "price_asc", "price_desc", "scraped_at_desc"],
format_func=lambda x: {
"score_desc": "Score (mejor primero)",
"price_asc": "Precio (menor primero)",
"price_desc": "Precio (mayor primero)",
"scraped_at_desc": "Más reciente primero",
}.get(x, x),
)
with c2:
page_size = st.selectbox("Resultados por página", [10, 25, 50, 100], index=1)
with c3:
search_only_no_hoa = st.checkbox(
"Solo sin HOA", value=False, key="search_only_no_hoa",
help="Confirmadas sin HOA (hoa_monthly=0). NULL=desconocido excluido.",
)
# Apply HOA filter (NULL = unknown, excluded by safety)
if search_only_no_hoa:
deals = [d for d in deals if (d.get("hoa_monthly") is not None and float(d.get("hoa_monthly") or 0) == 0)]
# Apply sort
if sort_by == "score_desc":
deals = sorted(deals, key=lambda d: (d.get("classification_score") or 0), reverse=True)
elif sort_by == "price_asc":
deals = sorted(deals, key=lambda d: (d.get("listing_price") or float("inf")))
elif sort_by == "price_desc":
deals = sorted(deals, key=lambda d: (d.get("listing_price") or 0), reverse=True)
elif sort_by == "scraped_at_desc":
deals = sorted(deals, key=lambda d: d.get("scraped_at") or "", reverse=True)
# Render cards
for d in deals[:page_size]:
_render_deal_card(d)
@st.cache_data(show_spinner=False, ttl=86400 * 30, max_entries=500)
def _resolve_streetview_photo(address: str, api_key: str) -> Optional[str]:
"""Return Google Street View static image URL ONLY si Google tiene imagery
para este address (status=OK). Si no (ZERO_RESULTS), devuelve None y la card
no muestra foto. Esto evita pagar $0.007 por placeholders 'no imagery'.
Caches 30 dias por (address, key) tuple — Street View imagery no cambia
frecuente y un cache hit es 0 cost vs $0.007 metadata call.
"""
import urllib.parse, urllib.request, json as _json
try:
enc = urllib.parse.quote(address)
meta_url = (
f"https://maps.googleapis.com/maps/api/streetview/metadata?"
f"location={enc}&key={api_key}"
)
with urllib.request.urlopen(meta_url, timeout=8) as resp:
data = _json.loads(resp.read().decode())
if data.get("status") != "OK":
return None
return (
f"https://maps.googleapis.com/maps/api/streetview?"
f"size=640x400&location={enc}&fov=80&key={api_key}"
)
except Exception:
return None
def _render_deal_card(d: dict):
"""Render one deal card with photo, info, and action buttons.
Layout: 2-column. Left = photo (or placeholder). Right = metadata + metrics.
Below: 5 action buttons (source, pre-screening, full report, favorite, dismiss).
"""
from property_type_inference import label_for as property_type_label_fn
# ─── Extract fields ────────────────────────────────────────────────────
deal_id = d["id"]
score = d.get("classification_score")
cls = d.get("classification_status") or "unclassified"
price = d.get("listing_price")
price_str = fmt_money(price) if price else "—"
starting_bid = d.get("starting_bid")
address = d.get("address") or f"Sin dirección · parcel {d.get('parcel_id', '—')}"
case_num = d.get("case_number")
source = d.get("source") or "—"
deal_type = d.get("deal_type") or "—"
property_type = d.get("property_type") or "unknown"
property_type_lbl = property_type_label_fn(property_type)
beds = d.get("beds")
baths = d.get("baths")
sqft = d.get("sqft")
county = d.get("county") or "—"
city = d.get("city") or ""
state = d.get("state") or ""
# Choose label for the identifier:
# - case_number = real court case (clerks) → "case#"
# - external_id = Zillow zpid / HUD case# → "zpid#" or "HUD#"
external_id = d.get("external_id")
source_l = (d.get("source") or "").lower()
if case_num:
id_label = f"case# {case_num}"
elif external_id:
if source_l == "zillow":
id_label = f"zpid {external_id}"
elif source_l == "hud_homestore":
id_label = f"HUD# {external_id}"
else:
id_label = f"ID {external_id}"
else:
id_label = ""
# Photo strategy (bug fix 2026-05-15, user directive):
# Card NEVER muestra fotos de Zillow (potentialmente mal asignadas y son fuente
# comercial, no oficial). Sequence:
# 1. PA photo (Broward bcpa.net Photographs/) — fuente oficial county
# 2. Google Street View Static API si GOOGLE_API_KEY configurado (free tier)
# 3. Sin foto + link al source para que el user vea (Wave 2 / Reporte completo
# sera el que use Zillow Zestimate + photos)
photo_url = None
photos_raw = d.get("photos_urls")
def _is_pa_photo(url: str) -> bool:
"""True if URL is from a County Property Appraiser (official source)."""
return any(domain in (url or "").lower() for domain in (
"bcpa.net", # Broward
"miamidade.gov", # Miami-Dade
"pbcpao.gov", # Palm Beach
"coj.net", # Duval (incl. maps.coj.net aerial)
))
pa_photos = []
if photos_raw:
try:
photos = json.loads(photos_raw) if isinstance(photos_raw, str) else photos_raw
if isinstance(photos, list):
pa_photos = [p for p in photos if p and _is_pa_photo(p)]
except Exception:
pa_photos = []
if pa_photos:
photo_url = pa_photos[0]
else:
# Try Google Street View fallback (free tier, OFFICIAL Google API).
# IMPORTANTE: chequear metadata PRIMERO (gratis) para evitar pagar $0.007
# por addresses sin imagery (Google devuelve placeholder pero igual factura).
google_key = os.getenv("GOOGLE_STREETVIEW_API_KEY") or os.getenv("GOOGLE_API_KEY")
if google_key and d.get("address"):
photo_url = _resolve_streetview_photo(d["address"], google_key)
# Classification badge mapping
badge_map = {
"potential_winner": ("badge-winner", "Potential winner"),
"maybe": ("badge-maybe", "Maybe"),
"pass": ("badge-pass", "Pass"),
"red_flag": ("badge-redflag", "Red flag"),
"unclassified": ("badge-neutral", "Sin clasificar"),
}
badge_cls, badge_label = badge_map.get(cls, ("badge-neutral", cls))
# URL detection (stale = generic HUD search result pre-B3 fix)
url = d.get("source_url") or ""
stale_patterns = ["searchresult?citystate=", "searchresult?cityState="]
is_stale = any(p in url for p in stale_patterns) if url else False
has_valid_url = bool(url) and not is_stale
# ─── HTML card frame ───────────────────────────────────────────────────
# Open card div
st.markdown('<div class="deal-card">', unsafe_allow_html=True)
col_img, col_info = st.columns([1, 3])
with col_img:
if photo_url:
st.markdown(
f'<img src="{photo_url}" class="deal-card-img" alt="property photo" '
f'onerror="this.outerHTML=&quot;<div class=\'deal-card-img-placeholder\'>Imagen no disponible</div>&quot;">',
unsafe_allow_html=True,
)
else:
st.markdown(
'<div class="deal-card-img-placeholder">Sin foto disponible</div>',
unsafe_allow_html=True,
)
with col_info:
# Header row: source + classification badge
score_html = (
f'<span style="font-variant-numeric:tabular-nums; font-weight:600; '
f'color:#0F172A; font-size:14px;">Score {score}/100</span>'
if score is not None else ""
)
sb_html = ""
if starting_bid and starting_bid != price:
sb_html = (
f'<span style="color:#64748B; font-size:12px; margin-left:8px;">'
f'starting bid {fmt_money(starting_bid)}</span>'
)
# Top line
st.markdown(
f'<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px;">'
f' <div style="flex:1; min-width:0;">'
f' <div style="font-size:11px; color:#64748B; text-transform:uppercase; letter-spacing:0.06em; font-weight:500;">'
f' <span class="badge badge-source">{source}</span>'
f' <span style="margin-left:8px; color:#94A3B8;">{deal_type}</span>'
f' </div>'
f' <div style="font-size:17px; font-weight:600; margin-top:6px; color:#0F172A; line-height:1.3;">'
f' {address}'
f' </div>'
f' <div style="font-size:13px; color:#64748B; margin-top:3px;">'
f' {county} County · {property_type_lbl} · '
f' {beds or "?"}bd / {baths or "?"}ba'
f' {f" · {sqft:,} sqft" if sqft else ""}'
f' {f" · {id_label}" if id_label else ""}'
f' </div>'
f' </div>'
f' <div style="text-align:right; white-space:nowrap;">'
f' <span class="badge {badge_cls}">{badge_label}</span>'
f' </div>'
f'</div>',
unsafe_allow_html=True,
)
# Metrics row
st.markdown(
f'<div style="display:flex; gap:28px; margin-top:14px; padding-top:12px; '
f'border-top:1px solid #F1F5F9;">'
f' <div>'
f' <div class="metric-label">Listing</div>'
f' <div class="metric-value">{price_str}</div>'
f' {sb_html if sb_html else ""}'
f' </div>'
+ (
f' <div>'
f' <div class="metric-label">Score</div>'
f' <div class="metric-value">{score}<span style="color:#94A3B8; font-size:14px;">/100</span></div>'
f' </div>'
if score is not None else ""
)
+ f' <div>'
f' <div class="metric-label">{state or "Location"}</div>'
f' <div class="metric-value metric-value-small">{city or county}</div>'
f' </div>'
f'</div>',
unsafe_allow_html=True,
)
# Pre-screening result (if user ran it this session) — render INSIDE card
ps_result = st.session_state.get(f"prescreen_result_{deal_id}")
if ps_result:
verdict = ps_result.get("verdict", "—")
verdict_class = {
"GO": "badge-go",
"MAYBE": "badge-maybe",
"NO-GO": "badge-nogo",
}.get(verdict, "badge-neutral")
margin = ps_result.get("margin_pct", 0)
eff_cost = ps_result.get("effective_cost", 0)
sur_debt = ps_result.get("surviving_debt_total", 0)
score10 = ps_result.get("score", 0)
reasoning = ps_result.get("reasoning", "")
st.markdown(
f'<div style="margin-top:14px; padding:12px; background:#F8FAFC; '
f'border-left:3px solid #1E3A8A; border-radius:0 8px 8px 0;">'
f' <div style="display:flex; justify-content:space-between; align-items:center;">'
f' <div style="font-size:11px; color:#64748B; text-transform:uppercase; letter-spacing:0.06em; font-weight:600;">'
f' Pre-screening result'
f' </div>'
f' <div>'
f' <span class="badge {verdict_class}">{verdict}</span>'
f' <span style="margin-left:8px; font-size:12px; color:#64748B;">Score {score10}/10</span>'
f' </div>'
f' </div>'
f' <div style="font-size:13px; color:#334155; margin-top:8px; line-height:1.5;">'
f' {reasoning}'
f' </div>'
f' <div style="display:flex; gap:24px; margin-top:10px; font-size:12px;">'
f' <div><span style="color:#64748B;">Surviving debt</span> '
f' <span style="font-weight:600; font-variant-numeric:tabular-nums;">${sur_debt:,}</span></div>'
f' <div><span style="color:#64748B;">Effective cost</span> '
f' <span style="font-weight:600; font-variant-numeric:tabular-nums;">${eff_cost:,}</span></div>'
f' <div><span style="color:#64748B;">Margin vs ARV</span> '
f' <span style="font-weight:600; font-variant-numeric:tabular-nums;">{margin:.1f}%</span></div>'
f' </div>'
f'</div>',
unsafe_allow_html=True,
)
red_flags = ps_result.get("red_flags") or []
if red_flags:
with st.expander(f"Red flags ({len(red_flags)})", expanded=False):
for rf in red_flags[:10]:
st.markdown(f"- {rf}")
# ─── FLIP-IN-PROGRESS ALERT (priority — show prominently) ──────────
pa_data = ps_result.get("property_appraiser") or {}
renov = pa_data.get("renovation_signal") or {}
if renov.get("is_flip_in_progress") or renov.get("is_flip_pattern"):
alert_title = "FLIP DETECTADO" if renov.get("is_flip_in_progress") else "Historial de flip"
st.warning(
f"**{alert_title}**: {renov.get('interpretation_es') or renov.get('evidence', '')}",
icon=None,
)
# ─── REO DIRECT OUTREACH ALERT (high-value detection) ──────────────
reo = ps_result.get("reo_signal") or {}
if reo.get("is_reo_opportunity"):
owner_cls = ps_result.get("owner_classification") or {}
lender_label = (reo.get("lender_type") or "").replace("_", " ").title()
offer_low = reo.get("suggested_offer_low") or 0
offer_high = reo.get("suggested_offer_high") or 0
discount = reo.get("discount_pct_vs_market") or 0
st.success(
f"### REO Direct Outreach Opportunity — {lender_label}\n\n"
f"**Owner es un {lender_label}** (matched: `{owner_cls.get('matched_keyword','')}`)\n\n"
f"**Oferta sugerida**: ${offer_low:,} - ${offer_high:,} "
f"({discount:.0f}% bajo market)\n\n"
f"**Estrategia**: {reo.get('strategy', '')}\n\n"
f"{reo.get('justification_es', '')}",
icon=None,
)
if reo.get("outreach_contact_hint"):
with st.expander("Cómo contactar al lender (outreach)"):
st.markdown(reo["outreach_contact_hint"])
# ─── FINANCIAL ANALYSIS section (NEW — bug fix 2026-05-15) ────────
fa = ps_result.get("financial_analysis") or {}
if fa:
with st.expander("Análisis financiero (oferta + cuotas + DTI)", expanded=True):
# Max profitable offer
mo = fa.get("max_profitable_offer") or {}
if mo.get("max_offer"):
st.markdown(f"**Oferta máxima rentable**: ${mo['max_offer']:,.0f} "
f"({mo.get('max_offer_pct_of_arv', 0):.0f}% del ARV)")
st.caption(mo.get("justification", ""))
# Payment table
pt = fa.get("payment_table") or []
if pt:
st.markdown("**Cuota mensual por precio (30 años, 6.5%, escrow incluido):**")
import pandas as pd
df = pd.DataFrame([
{
"Escenario": r["label"],
"Precio": f"${r['price']:,.0f}",
"Down": f"${r['down_payment']:,.0f}",
"Loan": f"${r['loan_amount']:,.0f}",
"P&I/mo": f"${r['p_and_i_only_monthly']:,.0f}",
"PITI/mo": f"${r['piti_monthly']:,.0f}",
} for r in pt
])
st.dataframe(df, use_container_width=True, hide_index=True)
# ── Live-in & DTI panels — solo si modo=live_in O hay income ──
analysis_mode = ps_result.get("_analysis_mode", "investor")
fa_inputs = fa.get("inputs") or {}
has_income = bool(fa_inputs.get("monthly_income") and fa_inputs["monthly_income"] > 0)
show_live_in = (analysis_mode == "live_in") or has_income
# Live-in scenario (gated)
li = fa.get("live_in_scenario") or {}
if show_live_in and li.get("piti_total_monthly"):
st.markdown(f"**Escenario para vivir** ({li.get('loan_type')} @ {li.get('rate_pct', 0):.2f}%):")
col1, col2 = st.columns(2)
with col1:
st.metric("Cuota total mensual (PITI)", f"${li['piti_total_monthly']:,.0f}")
if li.get("mip_or_pmi_label"):
st.caption(f"Incluye {li['mip_or_pmi_label']}: ${li.get('mip_or_pmi_monthly', 0):,.0f}/mo")
with col2:
if li.get("rent_room_monthly"):
st.metric("Pago NETO con renta cuarto",
f"${li['net_payment_monthly']:,.0f}",
delta=f"-${li['rent_room_monthly']:,.0f} renta")
else:
st.metric("Down payment",
f"${li.get('down_payment', 0):,.0f}",
delta=f"{li.get('down_pct', 0)}%")
# House-hack scenarios table (only if live-in + user puso rent_room)
scenarios = li.get("scenarios_with_room") or []
if scenarios and li.get("rent_room_monthly"):
st.markdown("**House-hack (rentar cuartos a distintos precios):**")
df_hh = pd.DataFrame([
{
"Renta cuarto": f"${s['rent_room_monthly']:,}" if s['rent_room_monthly'] else "Sin rentar",
"Pago neto/mo": f"${s['net_payment_monthly']:,.0f}",
} for s in scenarios
])
st.dataframe(df_hh, use_container_width=True, hide_index=True)
# DTI evaluation (solo si hay income)
dti = li.get("dti_evaluation") or {}
if dti and has_income:
verdict_colors = {
"AFFORDABLE": "success", "TIGHT": "warning",
"RISKY": "warning", "WONT_QUALIFY": "error",
}
v = dti.get("verdict", "")
color = verdict_colors.get(v, "info")
msg = f"**DTI: {v}** — {dti.get('verdict_es', '')}"
if color == "success":
st.success(msg)
elif color == "warning":
st.warning(msg)
elif color == "error":
st.error(msg)
else:
st.info(msg)
st.caption(
f"Front-end DTI {dti.get('front_end_dti_pct', 0)}% "
f"(máx {dti.get('front_end_max_pct', 0)}%) · "
f"Back-end {dti.get('back_end_dti_pct', 0)}% "
f"(máx {dti.get('back_end_max_pct', 0)}%). "
f"{dti.get('rationale', '')}"
)
elif show_live_in:
st.caption("DTI no evaluado — ingresá tu monthly income en los parámetros para verificar si podés pagar.")
# Saved path
pf = ps_result.get("property_folder")
if pf:
st.caption(f"Reporte guardado en: `{pf}`")
# ─── PLAYERS section (court records + plaintiff classification) ───
players = ps_result.get("players") or {}
if players.get("plaintiff_name") or players.get("implication"):
with st.expander("Players (plaintiff + court records)", expanded=False):
if players.get("plaintiff_name"):
st.markdown(f"**Plaintiff:** {players['plaintiff_name']}")
st.markdown(f"**Tipo:** `{players.get('plaintiff_type') or '—'}`")
if players.get("implication"):
st.markdown(f"**Implicación:** {players['implication']}")
# ─── Real public data fetched ─────────────────────────────────────
with st.expander("Fuentes consultadas (data pública)", expanded=False):
ds = ps_result.get("data_sources_status") or {}
# Source status icons:
# OK = ran and produced useful data
# WARN = ran but result inconclusive (UNKNOWN, no_comps_found)
# PEND = source exists publicly but our adapter is pending (HONEST)
# FAIL = didn't run or errored
_OK_STATUSES = {
"CLEAN", "LIS_PENDENS_ACTIVE", "CODE_VIOLATIONS",
"TAX_DELINQUENT", "FORECLOSURE_COMPLETE", "FORECLOSURE_PENDING",
"OWNER_VERIFIED", "NORMAL", "RED_FLAG", "CRITICAL_RED_FLAG",
"FOUND", "found",
}
_WARN_STATUSES = {
"UNKNOWN", "no_comps_found", "no_results", "INCONCLUSIVE",
}
_PENDING_STATUSES = {
"NOT_IMPLEMENTED", "not_implemented_for_county",
}
# Human-readable label override per status
_LABEL_OVERRIDE = {
"NOT_IMPLEMENTED": "scraper pendiente (data existe, no la buscamos)",
"not_implemented_for_county": "scraper pendiente (data existe, no la buscamos)",
"not_found": "buscamos y no estaba",
"no_comps_found": "buscamos, sin comps válidos",
"missing_inputs": "faltan inputs (zip/county/address)",
"missing_zip": "falta zip para comps",
"not_run": "no se ejecutó",
}
for source, status in ds.items():
if (status in _OK_STATUSES) or (isinstance(status, str) and status.startswith("found_")):
icon = "✓"
elif status in _WARN_STATUSES:
icon = "⚠"
elif status in _PENDING_STATUSES:
icon = "◌" # pending: source publica existe, falta scraper
else:
icon = "✗"
label = _LABEL_OVERRIDE.get(status, status)
st.markdown(f"- {icon} **{source}**: `{label}`")
# ─── PROPERTY APPRAISER (SOURCE OF TRUTH del county) ─────────────
# ALWAYS visible. Renders rich PA record from unified router cuando
# disponible. Si county no implementado o fetch fallo, muestra mensaje
# honesto + link al PA oficial del condado.
pa = ps_result.get("property_appraiser") or {}
pa_status = (ps_result.get("data_sources_status") or {}).get("property_appraiser", "")
deal_county = d.get("county") or "?"
deal_state = d.get("state") or "FL"
# County → URL mapping para PA oficial (links manuales si no implementado)
_COUNTY_PA_URLS = {
"duval": "https://paopropertysearch.coj.net",
"broward": "https://web.bcpa.net",
"miami-dade": "https://apps.miamidadepa.gov/PropertySearch/",
"palm beach": "https://pbcpao.gov",
"orange": "https://ocpaweb.ocpafl.org",
"hillsborough": "https://gis.hcpafl.org/propertysearch/",
"pinellas": "https://pcpao.gov",
"lee": "https://leepa.org",
"brevard": "https://bcpao.us",
"volusia": "https://vcpa.vcgov.org",
"polk": "https://polkpa.org",
"sarasota": "https://sc-pa.com",
"indian river": "https://www.ircpa.org",
"collier": "https://collierappraiser.com",
"manatee": "https://manateepao.com",
"marion": "https://marionpa.com",
"alachua": "https://acpafl.org",
"monroe": "https://mcpafl.com",
"st. johns": "https://sjcpa.us",
"st. lucie": "https://www.paslc.gov",
"clay": "https://ccpao.com",
"nassau": "https://nassauflpa.com",
"osceola": "https://property-appraiser.org",
"lake": "https://lakepa.us",
"seminole": "https://scpafl.org",
"pasco": "https://pascopa.com",
"escambia": "https://escpa.org",
}
county_key = deal_county.lower().replace(" county", "").strip()
official_pa_url = _COUNTY_PA_URLS.get(county_key, "")
# CASE A: county implementado y data extraida con owner
if pa and pa.get("owner_name"):
with st.expander(
f"Property Appraiser oficial — {pa.get('county', '?')} County",
expanded=True,
):
# === SECCION 1: IDENTIDAD + DUEÑOS ===
st.markdown("##### Dueños e identidad")
col1, col2 = st.columns(2)
with col1:
if pa.get("owner_name"):
st.markdown(f"**Owner principal:** `{pa['owner_name']}`")
co = pa.get("co_owners") or []
if co:
for c in co:
st.markdown(f"**Co-owner:** `{c}`")
if pa.get("parcel_id"):
st.markdown(f"**Parcel/Folio:** `{pa['parcel_id']}`")
if pa.get("site_address"):
st.markdown(f"**Site address:** {pa['site_address']}")
with col2:
if pa.get("mailing_address"):
st.markdown(f"**Mailing address:** `{pa['mailing_address']}`")
# Absentee owner detection
site = (pa.get("site_address") or "").upper()
mailing = (pa.get("mailing_address") or "").upper()
if site and mailing and site.split()[0] != mailing.split()[0]:
st.warning("Owner es ABSENTEE (mailing ≠ site)")
if pa.get("homestead_active") is not None:
if pa["homestead_active"]:
hs_amt = pa.get("homestead_amount") or 0
st.success(f"Homestead exemption ACTIVE (${hs_amt:,}) — owner-occupant")
else:
st.info("Sin homestead exemption — owner NO vive ahí (investor o segunda casa)")
if pa.get("subdivision"):
st.markdown(f"**Subdivisión:** {pa['subdivision']}")
st.divider()
# === SECCION 2: AVALÚO TAX + VALORES ===
st.markdown("##### Avalúo para impuestos (multi-año)")
col1, col2 = st.columns(2)
with col1:
if pa.get("just_value_current"):
ty = pa.get("tax_year_current", "")
st.metric(f"Just/Market Value {ty}", f"${pa['just_value_current']:,}")
if pa.get("assessed_value_current"):
ty = pa.get("tax_year_current", "")
st.metric(f"Assessed Value {ty} (SOH cap)", f"${pa['assessed_value_current']:,}")
with col2:
if pa.get("just_value_last"):
ty = pa.get("tax_year_last", "")
st.metric(f"Just Value {ty}", f"${pa['just_value_last']:,}")
if pa.get("taxes_paid_last"):
ty = pa.get("tax_year_last", "")
st.metric(f"Taxes pagados {ty}", f"${pa['taxes_paid_last']:,.2f}")
# Tax breakdown by district (Broward expone esto)
raw = pa.get("_raw") or {}
tax_breakdown = raw.get("tax_breakdown") or {}
if tax_breakdown and isinstance(tax_breakdown, dict):
with st.expander("Desglose de impuestos por distrito"):
for district, fields in tax_breakdown.items():
if not isinstance(fields, dict):
continue
just_v = fields.get("just_value") or 0
taxable_v = fields.get("taxable") or 0
homestead_v = fields.get("homestead") or 0
st.markdown(
f"- **{district.replace('_',' ').title()}**: "
f"Just ${just_v:,} | Taxable ${taxable_v:,} | "
f"Homestead ${homestead_v:,}"
)
st.divider()
# === SECCION 3: MEJORAS + DETALLES BUILDING ===
st.markdown("##### Mejoras y características")
col1, col2, col3 = st.columns(3)
with col1:
if pa.get("year_built"):
st.markdown(f"**Year built:** {pa['year_built']}")
if pa.get("effective_year_built"):
st.markdown(f"**Effective year:** {pa['effective_year_built']} _(renovación registrada)_")
if pa.get("building_type"):
st.markdown(f"**Building type:** {pa['building_type']}")
if pa.get("use_description") or pa.get("use_code"):
st.markdown(f"**Use:** {pa.get('use_description') or pa.get('use_code')}")
with col2:
if pa.get("bedrooms") is not None:
st.markdown(f"**Bedrooms:** {pa['bedrooms']}")
if pa.get("baths") is not None:
st.markdown(f"**Baths:** {pa['baths']}")
if pa.get("stories"):
st.markdown(f"**Stories:** {pa['stories']}")
if pa.get("sqft_heated"):
st.markdown(f"**Sqft heated:** {pa['sqft_heated']:,}")
if pa.get("sqft_total"):
st.markdown(f"**Sqft total:** {pa['sqft_total']:,}")
with col3:
if pa.get("lot_acres"):
st.markdown(f"**Lot:** {pa['lot_acres']} acres")
if pa.get("lot_total_sqft"):
st.markdown(f"**Lot sqft:** {pa['lot_total_sqft']:,}")
if pa.get("roof_type"):
st.markdown(f"**Roof struct:** {pa['roof_type']}")
if pa.get("roofing_cover"):
st.markdown(f"**Roof cover:** {pa['roofing_cover']}")
if pa.get("exterior_wall"):
st.markdown(f"**Exterior:** {pa['exterior_wall']}")
if pa.get("zoning"):
st.markdown(f"**Zoning:** {pa['zoning']}")
st.divider()
# === SECCION 4: TÍTULOS / SALES HISTORY + TITLE STATUS ===
st.markdown("##### Títulos (sales history)")
sales = pa.get("sales_history") or []
if sales:
# Title interpretation from MOST RECENT deed type
recent = sales[0]
deed_type = (recent.get("deed_type") or recent.get("qualification")
or recent.get("type") or "").upper()
recent_date = recent.get("date", "?")
recent_price = recent.get("price")
price_str = f"${recent_price:,}" if recent_price else "—"
# Title status interpretation
if "WARRANTY DEED" in deed_type and "SPECIAL" not in deed_type:
st.success(
f"**Título limpio (WARRANTY DEED)** — última transferencia "
f"{recent_date} por {price_str}. Warranty deed = vendor garantiza "
f"título limpio frente a claims previos."
)
elif "SPECIAL WARRANTY" in deed_type:
st.warning(
f"**SPECIAL WARRANTY DEED** — {recent_date} por {price_str}. "
f"Garantía LIMITADA (solo cubre actos del vendor mientras tuvo "
f"la propiedad). Típico en sales de bancos/REOs. Revisar title search."
)
elif "CERT OF TITLE" in deed_type or "CERTIFICATE OF TITLE" in deed_type:
st.error(
f"**CERTIFICATE OF TITLE** — {recent_date} por {price_str}. "
f"Deed por foreclosure/tax sale judicial. Pueden quedar "
f"liens senior no extinguidos. EXIGIR title search profesional."
)
elif "QUIT CLAIM" in deed_type or "QUITCLAIM" in deed_type:
st.warning(
f"**QUIT CLAIM DEED** — {recent_date} por {price_str}. "
f"Vendor NO garantiza nada. Solo transfiere lo que tenga "
f"(o no tenga). Bandera AMARILLA — verificar título a fondo."
)
elif "PERSONAL REPRESENTATIVE" in deed_type or "REP DEED" in deed_type:
st.info(
f"**PERSONAL REPRESENTATIVE'S DEED** — {recent_date} por {price_str}. "
f"Venta por estate (alguien murió). Validar probate completo + "
f"que no haya beneficiarios disputando."
)
elif "LIFE ESTATE" in deed_type:
st.info(
f"**LIFE ESTATE deed** — {recent_date}. Owner tiene derecho "
f"de vivir hasta morir; remainderman hereda. NO se puede vender "
f"sin acuerdo de TODOS los partes. Cuidado."
)
elif deed_type:
st.info(f"**Último deed:** {deed_type}{recent_date} por {price_str}")
# Full sales history table
with st.expander(f"Todas las transferencias ({len(sales)} registradas)",
expanded=False):
import pandas as pd
rows = []
for s in sales[:15]:
rows.append({
"Fecha": s.get("date", ""),
"Precio": f"${s.get('price'):,}" if s.get("price") else "—",
"Tipo de deed": (s.get("deed_type")
or s.get("qualification")
or s.get("type") or ""),
"Calificación": s.get("qualified", ""),
"Book/Page": s.get("book_page", "") or s.get("book_page_or_cin", ""),
})
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
else:
st.info("Sin sales history registrada en PA.")
st.divider()
# === SECCION 5: LEGAL DESCRIPTION + LINKS ===
if pa.get("legal_description"):
with st.expander("Legal description"):
st.text(pa["legal_description"])
if pa.get("source_url"):
st.markdown(
f"[Ver registro oficial en {pa.get('county','PA')} County PA →]({pa['source_url']})"
)
st.caption(f"Fuente: {pa.get('source', 'County Property Appraiser')}")
# CASE B: county implementado pero fetch fallo (server slow/error)
elif pa and pa.get("errors"):
with st.expander(
f"Property Appraiser oficial — {deal_county} County (fetch FALLO)",
expanded=True,
):
st.error(
f"PA del condado fue consultado pero **el server fallo**. "
f"Errores: {pa.get('errors')[0][:200] if pa.get('errors') else 'unknown'}"
)
st.markdown(
f"**Source primaria**: {deal_county} County Property Appraiser "
f"(oficina del condado, **no agente privado**)."
)
if official_pa_url:
st.markdown(f"[Hacer lookup manual ahora →]({official_pa_url})")
st.caption(
"Common causes: server del county lento, rate limit, o "
"address/parcel no reconocido. Re-correr en 1-2 min usualmente "
"funciona."
)
# CASE C: county NO implementado todavia — honesto + link
elif pa_status in ("not_implemented_for_county", "not_run", "missing_inputs") or not pa:
with st.expander(
f"Property Appraiser oficial — {deal_county} County",
expanded=True,
):
st.warning(
f"**Adapter del PA pendiente para {deal_county} County.**\n\n"
f"El Property Appraiser oficial del condado tiene TODA la info que "
f"necesitas: owner, taxes pagados, mejoras, copia de títulos (warranty "
f"deed/cert of title), homestead status, sales history, legal description.\n\n"
f"**NO usamos agentes privados** (Zillow/Realtor) para este campo — "
f"la fuente oficial gana siempre."
)
if official_pa_url:
st.markdown(f"### Lookup manual del PA oficial:")
st.markdown(
f"**[{deal_county} County Property Appraiser →]({official_pa_url})**"
)
st.caption(
f"Ingresá la dirección o parcel ID en ese sitio. Es el registro "
f"oficial del gobierno del condado, gratis, sin login."
)
else:
st.markdown(
f"_URL del PA oficial de {deal_county} no esta en mi mapeo. "
f"Google: '{deal_county} County Property Appraiser {deal_state}'._"
)
# Show tax_assessed legacy data if available (Broward old path)
tax = ps_result.get("tax_assessed")
if tax and tax.get("assessed_value"):
st.divider()
st.caption("**Data legacy disponible** (cobertura parcial):")
col1, col2 = st.columns(2)
with col1:
if tax.get("assessed_value"):
st.markdown(f"- Assessed: **${tax['assessed_value']:,}**")
if tax.get("year_built"):
st.markdown(f"- Year built: {tax['year_built']}")
with col2:
if tax.get("owner_name"):
st.markdown(f"- Owner: `{tax['owner_name']}`")
if tax.get("homestead_active") is not None:
st.markdown(f"- Homestead: {'SI' if tax['homestead_active'] else 'NO'}")
# Price validation result
pv = ps_result.get("price_validation")
if pv:
st.markdown("**Price validation:**")
st.markdown(f"- Status: `{pv.get('status', '?')}`")
if pv.get("market_estimate"):
st.markdown(f"- Market estimate: ${pv['market_estimate']:,}")
if pv.get("signed_max_discrepancy_pct") is not None:
pct = pv["signed_max_discrepancy_pct"]
st.markdown(f"- Discrepancy vs listing: {pct:+.1f}%")
if pv.get("sources_used"):
st.caption(f"_Sources: {', '.join(pv['sources_used'])}_")
# Comps
comps = ps_result.get("comps") or []
if comps:
st.markdown(f"**Comparables vendidos recientemente ({len(comps)}):**")
for c in comps[:3]:
price = c.get("sold_price")
addr = c.get("address") or "?"
sqft = c.get("sqft", "?")
psf = c.get("price_per_sqft")
sold_when = c.get("sold_date_text", "")
parts = []
if price:
parts.append(f"${price:,}")
if sqft and sqft != "?":
parts.append(f"{sqft:,}sqft")
if psf:
parts.append(f"${psf}/sqft")
parts.append(addr[:55])
if sold_when:
parts.append(f"_{sold_when}_")
st.markdown("- " + " · ".join(parts))
if ps_result.get("comps_estimate"):
st.markdown(f"- **Implied estimate**: ${ps_result['comps_estimate']:,}")
# ─── DUE DILIGENCE SYNTHESIS (Phase 3.5.D) — agente sintetiza TODO ──────
# Se renderiza si user ejecuto el boton "Reporte DD" o si el resultado
# esta en property folder (run previo).
dd_synth = st.session_state.get(f"dd_synth_{deal_id}")
if dd_synth and not dd_synth.get("error"):
verdict = dd_synth.get("verdict", "?")
confidence = dd_synth.get("confidence_score", 0)
verdict_color = {
"STRONG_BUY": "#047857", # dark green
"BUY": "#10B981", # green
"MAYBE": "#F59E0B", # amber
"PASS": "#EF4444", # red
"AVOID": "#7F1D1D", # dark red
}.get(verdict, "#64748B")
st.markdown(
f'<div style="margin-top:14px; padding:16px; background:#FAFBFD; '
f'border-left:4px solid {verdict_color}; border-radius:0 8px 8px 0;">'
f' <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">'
f' <div style="font-size:11px; color:#64748B; text-transform:uppercase; letter-spacing:0.06em; font-weight:600;">'
f' Due Diligence Report'
f' </div>'
f' <div style="font-size:24px; font-weight:700; color:{verdict_color};">{verdict}</div>'
f' </div>'
f' <div style="color:#1F2937; font-size:14px; line-height:1.5; margin-top:8px;">'
f'{dd_synth.get("executive_summary","")}'
f' </div>'
f' <div style="color:#64748B; font-size:12px; margin-top:6px;">'
f' Confidence: <strong>{confidence}/10</strong>'
f' </div>'
f'</div>',
unsafe_allow_html=True,
)
# Bid recommendation prominent
bid = dd_synth.get("bid_recommendation") or {}
if bid.get("mid"):
col1, col2, col3 = st.columns(3)
col1.metric("Oferta agresiva", f"${bid.get('low', 0):,.0f}")
col2.metric("Recomendada (mid)", f"${bid.get('mid', 0):,.0f}")
col3.metric("Walk-away ceiling", f"${bid.get('high', 0):,.0f}")
if bid.get("rationale"):
st.caption(f"_{bid['rationale']}_")
# Investment thesis
if dd_synth.get("investment_thesis"):
with st.expander("Tesis de inversión", expanded=False):
st.markdown(dd_synth["investment_thesis"])
# Top risks
risks = dd_synth.get("top_risks") or []
if risks:
with st.expander(f"Riesgos identificados ({len(risks)})", expanded=False):
for r in risks:
sev = r.get("severity", "?")
sev_color = {"HIGH": "#DC2626", "MED": "#F59E0B", "LOW": "#10B981"}.get(sev, "#64748B")
st.markdown(
f"**{r.get('risk','?')}** "
f"<span style='background:{sev_color}; color:white; padding:2px 8px; "
f"border-radius:4px; font-size:11px;'>{sev}</span>",
unsafe_allow_html=True,
)
if r.get("evidence"):
st.caption(f"_Evidencia_: {r['evidence']}")
if r.get("mitigation"):
st.markdown(f"_Mitigación_: {r['mitigation']}")
st.markdown("---")
# Action items
actions = dd_synth.get("action_items") or []
if actions:
with st.expander(f"Acciones recomendadas ({len(actions)})", expanded=False):
for a in actions:
st.markdown(f"- {a}")
# Must-have terms
terms = (bid.get("must_have_terms") or [])
if terms:
with st.expander(f"Términos obligatorios en la oferta", expanded=False):
for t in terms:
st.markdown(f"- {t}")
# Title + renovation assessment
if dd_synth.get("title_assessment") or dd_synth.get("renovation_assessment"):
with st.expander("Title + Renovation assessment", expanded=False):
if dd_synth.get("title_assessment"):
st.markdown(f"**Title:** {dd_synth['title_assessment']}")
if dd_synth.get("renovation_assessment"):
st.markdown(f"**Renovaciones:** {dd_synth['renovation_assessment']}")
# Data quality notes
notes = dd_synth.get("data_quality_notes") or []
if notes:
with st.expander(f"Limitaciones de la data ({len(notes)})", expanded=False):
for n in notes:
st.caption(f"- {n}")
# Comparable insight
if dd_synth.get("comparable_deals_insight"):
with st.expander("Análisis de comparables", expanded=False):
st.markdown(dd_synth["comparable_deals_insight"])
# Meta
meta = dd_synth.get("_meta") or {}
if meta:
st.caption(
f"_Generado en {meta.get('elapsed_seconds','?')}s · "
f"modelo: {meta.get('model','?')} · "
f"sources: {sum(1 for v in (meta.get('input_sources_present',{}) or {}).values() if v)}/9_"
)
elif dd_synth and dd_synth.get("error"):
st.error(f"Due Diligence synthesis falló: {dd_synth.get('error')[:200]}")
# Full-report result (if user ran it this session) — render expander with briefing
full_result = st.session_state.get(f"fullreport_result_{deal_id}")
if full_result:
eb = full_result.get("executive_briefing") or {}
briefing_md = eb.get("output") if isinstance(eb, dict) else None
coord = full_result.get("final") or {}
verdict_md = coord.get("output") if isinstance(coord, dict) else None
elapsed = full_result.get("total_seconds", 0)
defaults = full_result.get("_used_defaults", {})
st.markdown(
f'<div style="margin-top:14px; padding:12px; background:#F0FDF4; '
f'border-left:3px solid #047857; border-radius:0 8px 8px 0;">'
f' <div style="display:flex; justify-content:space-between; align-items:center;">'
f' <div style="font-size:11px; color:#64748B; text-transform:uppercase; letter-spacing:0.06em; font-weight:600;">'
f' Reporte completo'
f' </div>'
f' <div style="font-size:12px; color:#64748B;">Generado en {elapsed:.0f}s</div>'
f' </div>'
f' <div style="font-size:12px; color:#64748B; margin-top:6px;">'
f' Análisis ejecutado con <b>heuristic defaults</b> para campos financieros '
f'(rent 1% rule, tax 2%, ins 0.6%, rehab 10%). '
f'Para precisión, editar inputs en "Análisis" del sidebar.'
f' </div>'
f'</div>',
unsafe_allow_html=True,
)
# Two expanders: Briefing (clean) + Veredicto Coordinator (raw)
if briefing_md:
with st.expander("Briefing ejecutivo", expanded=True):
st.markdown(briefing_md)
if verdict_md:
with st.expander("Veredicto del Coordinator (markdown crudo)", expanded=False):
st.markdown(verdict_md)
with st.expander("Defaults heurísticos usados", expanded=False):
for k, v in defaults.items():
st.markdown(f"- **{k}**: `{v}`")
# Optional reasoning expander (DealClassifier)
reasons_raw = d.get("classification_reasons", "[]")
try:
reasons = json.loads(reasons_raw) if reasons_raw else []
except Exception:
reasons = []
if reasons:
with st.expander("Razonamiento del clasificador", expanded=False):
for r in reasons[:6]:
st.markdown(f"- {r}")
# ─── Action buttons (5 columns) ────────────────────────────────────────
b1, b2, b3, b4, b5 = st.columns(5)
with b1:
if has_valid_url:
st.link_button("Ver fuente", url, use_container_width=True)
elif case_num:
st.button("Sin link directo", key=f"stale_link_{deal_id}",
disabled=True, use_container_width=True,
help=f"Buscar manualmente con case# {case_num} en {source}")
else:
st.button("Sin fuente", key=f"no_link_{deal_id}",
disabled=True, use_container_width=True)
with b2:
if st.button("Pre-screening", key=f"prescreen_{deal_id}",
use_container_width=True,
help="Análisis con PA + court records + financial analysis (DTI, max offer, live-in)"):
st.session_state[f"prescreen_open_{deal_id}"] = True
st.rerun()
# ── Pre-screening inputs panel (gated by button) ────────────────────────
if st.session_state.get(f"prescreen_open_{deal_id}"):
with st.container(border=True):
st.markdown("**Parametros del análisis financiero**")
# ── Mode toggle: investor vs live-in ────────────────────────────
ps_mode = st.radio(
"Modo de análisis",
options=["investor", "live_in"],
format_func=lambda x: {"investor": "Para invertir (flip/rent)",
"live_in": "Para vivir (owner-occupied)"}.get(x, x),
horizontal=True,
key=f"ps_mode_{deal_id}",
help="Para vivir = FHA/Convencional + DTI evaluation. Para invertir = max offer + flip math.",
)
colA, colB, colHOA = st.columns(3)
with colA:
ps_down = st.number_input(
"Down payment (USD)", min_value=0,
value=int((d.get("listing_price") or 0) * 0.10) or 30000,
step=5000, key=f"ps_down_{deal_id}",
)
with colB:
if ps_mode == "live_in":
ps_loan_type = st.selectbox(
"Tipo de préstamo",
options=["conventional_oo", "fha", "va"],
format_func=lambda x: {"conventional_oo": "Convencional OO (6.5%)",
"fha": "FHA primer hogar (6.25%, 3.5% down)",
"va": "VA militar (6.0%, 0% down)"}.get(x, x),
key=f"ps_loan_{deal_id}",
)
else:
# Investor: hint que se usa DSCR/conventional investor
st.text_input("Modo investor", value="DSCR/Conv-Investor (7.75%) — default",
disabled=True, key=f"ps_loan_inv_{deal_id}")
ps_loan_type = "conventional_oo" # not used in investor mode
with colHOA:
# Pre-fill HOA del deal si conocemos, sino 0
default_hoa = int(d.get("hoa_monthly") or 0)
ps_hoa = st.number_input(
"HOA mensual (USD)", min_value=0,
value=default_hoa, step=25,
key=f"ps_hoa_{deal_id}",
help="Cuota mensual de la HOA. 0 si no tiene HOA. Si el deal viene de Zillow con HOA detectado, se prefilea.",
)
# ── Live-in only inputs (DTI + house-hack) ──────────────────────
ps_income = 0
ps_other_debts = 0
ps_rent_room = 0
if ps_mode == "live_in":
st.markdown("_Datos para DTI evaluation (opcional — si los das calculo si podes pagar):_")
colC, colD, colE = st.columns(3)
with colC:
ps_income = st.number_input(
"Ingreso bruto mensual (USD)", min_value=0,
value=0, step=500, key=f"ps_income_{deal_id}",
help="Si lo pones, calculo DTI y verifico si podes pagar la cuota.",
)
with colD:
ps_other_debts = st.number_input(
"Otras deudas mensuales (USD)", min_value=0,
value=0, step=100, key=f"ps_debts_{deal_id}",
help="Carro, student loan, credit cards minimum payments",
)
with colE:
ps_rent_room = st.number_input(
"Renta cuarto/mo (house-hack)", min_value=0,
value=0, step=100, key=f"ps_room_{deal_id}",
help="Si alquilas un cuarto, reduce tu pago neto",
)
run_btn_col, cancel_btn_col = st.columns([2, 1])
with run_btn_col:
if st.button("Ejecutar pre-screening", key=f"ps_run_{deal_id}",
type="primary", use_container_width=True):
from pre_screening_orchestrator import run_pre_screening
with st.spinner("Ejecutando pre-screening (PA + court + financial)..."):
ps_result = run_pre_screening(
deal=d,
down_payment=float(ps_down),
monthly_income=float(ps_income) if ps_income else None,
rent_room_monthly=float(ps_rent_room) if ps_rent_room else None,
other_monthly_debts=float(ps_other_debts) if ps_other_debts else None,
loan_type=ps_loan_type,
hoa_monthly_override=float(ps_hoa) if ps_hoa else 0.0,
)
# Tag the analysis mode for downstream rendering
ps_result["_analysis_mode"] = ps_mode
st.session_state[f"prescreen_result_{deal_id}"] = ps_result
st.session_state[f"prescreen_open_{deal_id}"] = False
st.toast(f"Pre-screening: {ps_result.get('verdict')} ({ps_result.get('score')}/10)")
st.rerun()
with cancel_btn_col:
if st.button("Cancelar", key=f"ps_cancel_{deal_id}", use_container_width=True):
st.session_state[f"prescreen_open_{deal_id}"] = False
st.rerun()
with b3:
# Nuevo: Reporte DD = corre run_full_dd (incluye DueDiligenceCoordinator
# synthesis al final). Reemplaza el viejo analyze_deal_from_db (7 agentes
# legacy). El DDA da: executive summary + verdict + risks + bid + actions.
existing_dd = st.session_state.get(f"dd_synth_{deal_id}")
btn_label = "Re-correr DD" if existing_dd else "Reporte DD"
if st.button(btn_label, key=f"dd_report_{deal_id}",
use_container_width=True,
help="Reporte Due Diligence completo (~3-5 min): PA + court records "
"+ FEMA + NOAA + neighborhood + DueDiligenceCoordinator AI "
"synthesis con verdict + risks + bid recommendation."):
with st.status("Ejecutando Due Diligence completo (~3-5 min)...",
expanded=True) as status:
def _dd_cb(msg):
status.write(msg)
try:
from dd_orchestrator import run_full_dd
dd_result = run_full_dd(deal_id, status_cb=_dd_cb)
synth = dd_result.get("synthesis") or {}
st.session_state[f"dd_synth_{deal_id}"] = synth
elapsed = dd_result.get("elapsed_seconds", 0)
verdict = synth.get("verdict", "?")
status.update(
label=f"DD completo en {elapsed:.0f}s — verdict={verdict}",
state="complete",
)
st.toast(f"DD: {verdict} · confidence {synth.get('confidence_score','?')}/10")
st.rerun()
except Exception as e:
status.update(label=f"Error: {e}", state="error")
# OLD 7-agent pipeline disabled — DueDiligenceCoordinator (b3 nuevo) lo reemplaza.
# Si necesitas re-habilitar el 7-agent pipeline, ver git history pre-DDA.
with b4:
is_favorite = (d.get("status") == config.DEAL_STATUS_INTERESTING)
if is_favorite:
if st.button("Quitar favorito", key=f"unfav_{deal_id}",
use_container_width=True):
from deals_db import update_status
try:
update_status(deal_id, config.DEAL_STATUS_VIEWED)
st.toast("Removido de favoritos")
st.rerun()
except Exception as e:
st.error(f"{e}")
else:
if st.button("Favorito", key=f"fav_{deal_id}",
use_container_width=True):
from deals_db import update_status
try:
update_status(deal_id, config.DEAL_STATUS_INTERESTING)
st.toast("Agregado a favoritos")
st.rerun()
except Exception as e:
st.error(f"{e}")
with b5:
if st.button("Descartar", key=f"dismiss_{deal_id}",
use_container_width=True):
from deals_db import update_status
try:
update_status(deal_id, config.DEAL_STATUS_DISMISSED)
st.toast("Descartado")
st.rerun()
except Exception as e:
st.error(f"{e}")
# Close card div
st.markdown('</div>', unsafe_allow_html=True)
def _build_deal_inputs_from_db(deal: dict):
"""Construye DealInputs desde un deal scrapeado, con heuristic defaults
para los campos que el scraper NO trae (rent / taxes / insurance / hoa / arv).
Returns (DealInputs, BuyerProfile, used_defaults: dict) — la tercera nos sirve
para mostrar al usuario QUE defaults se usaron.
Heuristic defaults FL típicos:
- property_tax = 2.0% del price/year
- insurance = 0.6% del price/year (inland) — costa puede ser 2-3%
- hoa = 0 (default; SFR sin HOA es lo más común)
- rent = 1% rule (price * 0.01 mensual)
- arv = estimated_arv del deal, o price * 1.15 si no
- rehab = 10% del price (conservador)
- sqft = 1500 (median FL SFR)
- beds/baths = del deal, o 3/2 default
- year_built = del deal, o 1985 default
"""
from orchestrator import DealInputs, BuyerProfile
address = deal.get("address") or f"Sin direccion · parcel {deal.get('parcel_id', '?')}"
price = float(
deal.get("listing_price")
or deal.get("starting_bid")
or deal.get("estimated_arv")
or 200_000
)
arv_from_db = deal.get("estimated_arv")
arv = float(arv_from_db) if arv_from_db else round(price * 1.15)
beds = deal.get("beds") or 3
baths = deal.get("baths") or 2.0
sqft = deal.get("sqft") or 1500
year_built = deal.get("year_built") or 1985
# FL typical heuristics
rent = round(price * 0.01) # 1% rule
property_tax = round(price * 0.020) # ~2% FL average
insurance = round(price * 0.006) # ~0.6% inland; coastal higher
hoa = 0
rehab_override = round(price * 0.10) # 10% conservative
deal_inputs = DealInputs(
address=address,
price=price,
rent=float(rent),
property_tax=float(property_tax),
insurance=float(insurance),
hoa=float(hoa),
sqft=int(sqft),
beds=int(beds),
baths=float(baths),
year_built=int(year_built),
arv=float(arv),
rehab_override=float(rehab_override),
deal_type=deal.get("deal_type") or "mls",
case_number=deal.get("case_number"),
occupancy_status="unknown",
property_type=(deal.get("property_type") if deal.get("property_type") not in (None, "", "unknown") else "sfr"),
listing_description=deal.get("listing_description"),
)
# Default buyer profile (Class C, no extras). User can re-run on Analisis page
# if they have a different profile.
profile = BuyerProfile(
profile_class="C",
fico=None,
llc_seasoning_years=None,
capital_available=None,
experience_deals=None,
nationality=None,
visa_status=None,
)
used_defaults = {
"rent_estimated_1pct_rule": rent,
"property_tax_pct_of_price": "2.0%",
"insurance_pct_of_price": "0.6%",
"hoa_default": 0,
"rehab_override_pct_of_price": "10%",
"arv_from": "deal.estimated_arv" if arv_from_db else "price * 1.15 (heuristic)",
"profile_class": "C (default)",
"year_built_from_deal": deal.get("year_built") is not None,
"sqft_from_deal": deal.get("sqft") is not None,
"beds_from_deal": deal.get("beds") is not None,
}
return deal_inputs, profile, used_defaults
def analyze_deal_from_db(deal_id: int, status_cb=None) -> dict:
"""Corre el pipeline completo de análisis sobre un deal de deals.db.
Usa heuristic defaults para los campos financieros que el scraper no trae.
Retorna un dict-like (AnalysisResult convertido a dict + metadata).
"""
from deals_db import init_db, get_deal_by_id
from orchestrator import analyze_deal
init_db()
deal = get_deal_by_id(deal_id)
if not deal:
raise ValueError(f"Deal id={deal_id} no encontrado en deals.db")
deal_inputs, profile, used_defaults = _build_deal_inputs_from_db(deal)
result = analyze_deal(deal_inputs, profile, photo_bytes=None, status_cb=status_cb)
# Convert dataclass result to plain dict for session_state storage
import dataclasses
if dataclasses.is_dataclass(result):
result_dict = dataclasses.asdict(result)
else:
result_dict = dict(result) if not isinstance(result, dict) else result
result_dict["_used_defaults"] = used_defaults
return result_dict
def _prefill_manual_form_from_deal(d: dict) -> None:
"""Llena los campos del formulario de Analisis manual desde un deal scrapeado.
Solo precarga lo que el scraper trae (address, price, beds/baths/sqft/year_built,
case_number, deal_type, property_type, listing_description). Los campos financieros
del usuario (rent, taxes, insurance, HOA, ARV) los completa el usuario porque
requieren research que el scraper no hace.
Setea st.session_state['prefilled_from_inventory'] para que page_manual_analysis
pueda mostrar un banner de aviso.
"""
st.session_state["prefilled_from_inventory"] = {
"deal_id": d.get("id"),
"source": d.get("source"),
"address": d.get("address") or "(sin address)",
}
# Address + identifiers
if d.get("address"):
st.session_state["form_address"] = d["address"]
# Price: prefer listing_price, fallback a starting_bid
price = d.get("listing_price") or d.get("starting_bid") or 0
if price:
st.session_state["form_price"] = int(price)
# Physical attributes
if d.get("beds") is not None:
st.session_state["form_beds"] = int(d["beds"])
if d.get("baths") is not None:
st.session_state["form_baths"] = float(d["baths"])
if d.get("sqft"):
st.session_state["form_sqft"] = int(d["sqft"])
if d.get("year_built"):
st.session_state["form_year_built"] = int(d["year_built"])
# Deal metadata
if d.get("deal_type"):
st.session_state["form_deal_type"] = d["deal_type"]
if d.get("case_number"):
st.session_state["form_case_number"] = d["case_number"]
if d.get("property_type"):
# Fallback "unknown" → "sfr" para que el form acepte
pt = d["property_type"] if d["property_type"] != "unknown" else "sfr"
st.session_state["form_property_type"] = pt
if d.get("listing_description"):
st.session_state["form_listing_description"] = d["listing_description"]
def page_feed():
st.title("Feed de oportunidades")
st.info(
"**Pendiente de Phase 3E.** "
"Acá va a aparecer el feed automático de deals encontrados por el DealFinder "
"(Phase 3B-D). Por ahora, usá la página **Buscar deals** para scrapear "
"on-demand y **Histórico** para ver corridas previas."
)
def page_markets():
"""Mercados — cobertura real (markets_database x scrapers registry)."""
from scrapers.registry import list_sources, get_sources_for_county
from deals_db import init_db, list_deals
init_db()
st.title("Cobertura de mercados")
st.caption(
"Vista de qué condados están **cubiertos** por scrapers específicos vs los "
"que solo dependen de fuentes nacionales (HUD) o pagas (Zillow)."
)
# ─── Section 1: Sources registrados (overview) ──────────────────────────
all_sources = list_sources()
free_count = sum(1 for s in all_sources if s.get("free"))
paid_count = sum(1 for s in all_sources if not s.get("free"))
clerk_count = sum(1 for s in all_sources if "clerk" in s["id"])
c1, c2, c3, c4 = st.columns(4)
c1.metric("Total sources", len(all_sources))
c2.metric("Free (Playwright)", free_count)
c3.metric("Pagos (Firecrawl)", paid_count)
c4.metric("County clerks (FL)", clerk_count)
with st.expander(f"Ver los {len(all_sources)} sources registrados", expanded=False):
for s in all_sources:
free_tag = "FREE" if s.get("free") else f"{s.get('firecrawl_credits_per_run', '?')} cr/run"
scope = s.get("scope", "?")
counties_sup = s.get("supported_counties")
if counties_sup is None:
cov = f"{scope} (cualquier {scope})"
else:
cov = f"{scope}: {', '.join(counties_sup)}"
st.markdown(f"- **`{s['id']}`** · {free_tag} · {cov}")
st.caption(f" {s.get('description', '')[:160]}")
st.divider()
# ─── Section 2: Cobertura county-by-county ──────────────────────────────
st.subheader("Cobertura por condado")
try:
with open(config.MARKETS_DB_FILE, encoding="utf-8") as f:
db = json.load(f)
except Exception as e:
st.error(f"No pude leer {config.MARKETS_DB_FILE}: {e}")
return
# Build a deal-count map per (county, state) para mostrar deals reales
all_deals_for_count = list_deals(limit=10000)
deal_count_by_county = {}
for d in all_deals_for_count:
cty = d.get("county")
if cty:
deal_count_by_county[cty] = deal_count_by_county.get(cty, 0) + 1
for region, mkts in db["regions"].items():
if not isinstance(mkts, list):
continue
# Count coverage: cuántos del region tienen clerk específico
with_clerk = 0
for m in mkts:
srcs = get_sources_for_county(m.get("county", ""))
if any("clerk" in s["id"] for s in srcs):
with_clerk += 1
# Header del expander con resumen de cobertura
cov_emoji = "" if with_clerk == len(mkts) else ("" if with_clerk > 0 else "")
with st.expander(
f"{cov_emoji} {region}{with_clerk}/{len(mkts)} con clerk dedicado",
expanded=False,
):
for m in mkts:
cty = m.get("county", "?")
state = m.get("state", "?")
cities = ", ".join(m.get("main_cities", []))
notes = m.get("notes", "")
deal_count = deal_count_by_county.get(cty, 0)
# Coverage detail
srcs = get_sources_for_county(cty)
clerk_srcs = [s["id"] for s in srcs if "clerk" in s["id"]]
national_srcs = [s["id"] for s in srcs if "clerk" not in s["id"]]
cov_bits = []
if clerk_srcs:
cov_bits.append(f"clerk: `{', '.join(clerk_srcs)}`")
else:
cov_bits.append("sin clerk dedicado")
if national_srcs:
cov_bits.append(f"+ {', '.join(national_srcs)}")
cov_line = " · ".join(cov_bits)
deal_badge = f" · **{deal_count} deals scrapeados**" if deal_count else ""
notes_line = f" — _{notes}_" if notes else ""
st.markdown(f"- **{cty}, {state}** · {cities}{deal_badge}{notes_line}")
st.caption(f" {cov_line}")
st.divider()
# ─── Section 3: Presets ─────────────────────────────────────────────────
st.subheader("Presets disponibles")
presets = db.get("presets") or {}
if presets:
for name, ids in presets.items():
with st.expander(f"{name} ({len(ids)} mercados)", expanded=False):
# Resolve IDs → county names if possible
resolved = []
if isinstance(ids, list):
for entry in ids:
if isinstance(entry, dict):
resolved.append(f"{entry.get('county')} ({entry.get('state')})")
else:
resolved.append(str(entry))
st.write(", ".join(resolved) if resolved else "(empty)")
else:
st.caption("(no hay presets definidos)")
st.divider()
# ─── Section 4: Gaps conocidos ──────────────────────────────────────────
st.subheader("Limitaciones conocidas")
st.markdown(
"""
- **Palm Beach (West Palm Beach)** — NO está en realauction.com. Investigado:
- `mypalmbeach.realforeclose.com` → Realauction parking page
- `palmbeach.realtimeauction.com` → HugeDomains parking
- `mypalmbeachclerk.com` → Cloudflare 403
- Plataforma real desconocida — requiere investigación manual del sitio del clerk
- **Counties FL fuera de los Top 16** (e.g., Alachua/Gainesville, Leon/Tallahassee, Collier/Naples)
— solo cobiertos por HUD + Zillow
- **Property type para deals del clerk** queda "unknown" porque el HTML del clerk no
trae beds/baths. Mejorable con parser appraiser (otro fetch por deal).
"""
)
# ════════════════════════════════════════════════════════════════════════════
# Router
# ════════════════════════════════════════════════════════════════════════════
def main():
init_state()
page = render_sidebar()
{
"manual": page_manual_analysis,
"search": page_search_deals,
"inventory": page_inventory,
"favorites": page_favorites,
"history": page_history,
"feed": page_feed,
"markets": page_markets,
}[page]()
if __name__ == "__main__":
main()