3824 lines
169 KiB
Python
3824 lines
169 KiB
Python
"""
|
||
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 (2–4 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.8–2.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="<div class=\'deal-card-img-placeholder\'>Imagen no disponible</div>"">',
|
||
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()
|