Files
AR-House/pre_screening_orchestrator.py
T
2026-07-03 12:24:58 -04:00

1088 lines
51 KiB
Python

"""pre_screening_orchestrator.py — Capa 1 análisis rápido (1-2 min).
OBJETIVO:
Antes de gastar 5-7 min en el pipeline completo de 8+ agentes (Capa 2), una
verificación express:
1. Lookup court records si está disponible para el county (Duval por ahora)
2. Pre-cálculo en Python de surviving liens según reglas FL
3. Una llamada al LienPositionAnalyzer para interpretar y dar veredicto
OUTPUT:
{
"verdict": "GO" | "MAYBE" | "NO-GO",
"score": 0-10,
"reasoning": "string",
"purchase_price": int,
"arv": int,
"rehab_budget": int,
"surviving_debt_total": int,
"effective_cost": int,
"margin_pct": float,
"red_flags": [str, ...],
"liens_detected": [...],
"data_sources_used": [...],
"elapsed_seconds": float,
"errors": [str, ...]
}
USO desde Streamlit:
from pre_screening_orchestrator import run_pre_screening
result = run_pre_screening(deal=deal_dict_from_db, arv_override=None)
"""
from __future__ import annotations
import json
import os
import time
from typing import Callable, Optional
# ────────────────────────────────────────────────────────────────────────────
# FL lien survival rules — what survives a foreclosure judicial sale
# Lifted from data_fetchers/court_records.py analyze_lien_survival().
# ────────────────────────────────────────────────────────────────────────────
# Lien types we recognize (string canon)
LIEN_PROPERTY_TAX = "property_tax"
LIEN_IRS = "irs_tax"
LIEN_STATE_TAX = "state_tax"
LIEN_MUNICIPAL = "municipal" # code enforcement / utility
LIEN_HOA = "hoa"
LIEN_MORTGAGE_PRIMARY = "mortgage_primary"
LIEN_MORTGAGE_JUNIOR = "mortgage_junior"
LIEN_MECHANIC = "mechanic"
LIEN_JUDGMENT = "judgment"
LIEN_UNKNOWN = "unknown"
# Cap rules (FL): HOA inherits limited amount post-foreclosure
_HOA_INHERITED_CAP_MONTHS = 12 # FL 720.3085(2)(b)
def _is_servicer_plaintiff(plaintiff: Optional[str]) -> bool:
"""Heuristic: is the plaintiff a loan servicer (vs an investor)?
Servicers usually mean the mortgage extinguishes cleanly.
"""
if not plaintiff:
return False
p = plaintiff.upper()
servicers = [
"WELLS FARGO", "MR COOPER", "MR. COOPER", "SHELLPOINT", "BSI FINANCIAL",
"NEWREZ", "PHH MORTGAGE", "SPECIALIZED LOAN SERVICING", "SLS",
"BANK OF AMERICA", "JPMORGAN", "CHASE", "CITIBANK", "FREEDOM MORTGAGE",
"DITECH", "OCWEN", "NATIONSTAR", "ROCKET MORTGAGE", "QUICKEN LOANS",
"PENNYMAC", "LOANDEPOT", "CALIBER HOME LOANS",
]
return any(s in p for s in servicers)
def calculate_surviving_debt(
*,
liens_detected: list[dict],
deal_type: str,
plaintiff: Optional[str] = None,
) -> tuple[int, list[dict]]:
"""Sum the dollar amount of liens that SURVIVE the foreclosure/tax-deed sale.
Returns (total_surviving_dollars, enriched_liens_list).
Each enriched lien gets a `survives` boolean + `survival_reason`.
"""
plaintiff_is_servicer = _is_servicer_plaintiff(plaintiff)
total = 0
enriched = []
for lien in liens_detected:
lien_type = (lien.get("type") or LIEN_UNKNOWN).lower()
amount = lien.get("amount") or 0
survives = False
reason = ""
if deal_type == "tax_deed":
# Tax deed sale extinguishes most liens (paid from surplus)
# EXCEPT: HOA, municipal, federal liens
if lien_type in (LIEN_HOA, LIEN_MUNICIPAL, LIEN_IRS):
survives = True
reason = f"{lien_type} survives tax deed sale (runs with land)"
else:
survives = False
reason = "Extinguished by tax deed (paid from sale surplus)"
elif deal_type in ("foreclosure", "auction"):
# Foreclosure rules
if lien_type == LIEN_PROPERTY_TAX:
survives = True
reason = "Property tax has super-priority (FL 197.122)"
elif lien_type == LIEN_IRS:
survives = True
reason = "IRS lien survives w/ 120-day redemption (26 USC 7425(d))"
elif lien_type == LIEN_MUNICIPAL:
survives = True
reason = "Municipal liens run with the land (FL 162.09)"
elif lien_type == LIEN_HOA:
survives = True
# FL HOA cap: 12 months OR 1% of original mortgage (whichever lower)
capped = min(amount, _HOA_INHERITED_CAP_MONTHS * (amount / 12) if amount else 0)
reason = f"HOA capped at ~12 mo dues (FL 720.3085(2)(b))"
amount = int(capped) if capped else amount
elif lien_type == LIEN_MORTGAGE_PRIMARY:
if plaintiff_is_servicer:
survives = False
reason = f"Plaintiff ({plaintiff}) is loan servicer — primary mortgage extinguishes"
else:
survives = True
reason = "Primary mortgage may survive if plaintiff is HOA / municipality"
elif lien_type == LIEN_MORTGAGE_JUNIOR:
survives = False
reason = "Junior mortgages extinguished if properly noticed"
elif lien_type == LIEN_STATE_TAX:
survives = True
reason = "State tax lien survives unless paid from surplus"
else:
survives = True # default conservative — assume survives, flag for review
reason = f"{lien_type} survival uncertain — flag for title search"
else:
# MLS / REO / unknown deal_type → assume normal title transfer (no inherited debt)
survives = False
reason = "Standard sale: title transfers clean (escrow handles payoff)"
if survives:
total += amount
enriched.append({
**lien,
"survives": survives,
"survival_reason": reason,
"amount_post_cap": amount,
})
return total, enriched
# ────────────────────────────────────────────────────────────────────────────
# Main entry point
# ────────────────────────────────────────────────────────────────────────────
DISTRESSED_DEAL_TYPES = {"foreclosure", "auction", "tax_deed", "reo"}
def run_pre_screening(
*,
deal: dict,
arv_override: Optional[int] = None,
rehab_budget_override: Optional[int] = None,
# ─── Financial analysis params (NUEVOS, bug fix 2026-05-15) ─────────
down_payment: Optional[float] = None, # cash to put down (default 5% of asking)
monthly_income: Optional[float] = None, # buyer gross monthly income (for DTI)
rent_room_monthly: Optional[float] = None, # house-hack: rent a room
other_monthly_debts: Optional[float] = None, # other debts (car, student, cc)
loan_type: str = "conventional_oo", # 'fha' | 'conventional_oo' | 'va'
hoa_monthly_override: Optional[float] = None, # HOA mensual user input
status_cb: Optional[Callable[[str], None]] = None,
) -> dict:
"""Ejecuta el pre-screening sobre un deal scrapeado.
HARD RULE (2026-05): si deal_type es distressed OR case_number != null,
court_records es OBLIGATORIO. No hay skip. Si court_records no se puede
ejecutar (county no soportado), LienPositionAnalyzer marca el verdict
como INSUFFICIENT_DATA.
Args:
deal: dict desde deals_db.list_deals/get_deal_by_id
arv_override: ARV manual si el user lo provee (sino se infiere de assessed_value)
rehab_budget_override: rehab manual (sino se aplica logica evidence-based)
status_cb: callback para progreso
Returns:
dict con verdict / score / red_flags / players / metrics.
BUG FIX 2026-05-15: pre-screening NO inspeciona fotos (PhotoInspector es
Wave 2). Antes se asumia rehab = 10% del listing CIEGAMENTE — inventaba
$30K de reparacion para una propiedad recien renovada. Fix: rehab=0 por
default, label honesto "no evaluado en pre-screening", override solo si
user provee.
"""
t0 = time.perf_counter()
errors: list[str] = []
data_sources: list[str] = []
def _log(msg: str) -> None:
if status_cb:
status_cb(msg)
# ─── Inputs from deal ──────────────────────────────────────────────────
deal_type = (deal.get("deal_type") or "").lower()
purchase_price = (
deal.get("listing_price")
or deal.get("starting_bid")
or 0
)
assessed_value = deal.get("estimated_arv") or 0
arv = arv_override or assessed_value or (int(purchase_price * 1.3) if purchase_price else 0)
# Rehab evidence-based: solo aplicamos si user override; sino 0 + label honesto.
# Pre-screening NO tiene PhotoInspector. Inventar rehab por edad es engañoso.
# Wave 2 / Reporte Completo es el que hace inspeccion fotografica + age deductions.
rehab_budget = rehab_budget_override if rehab_budget_override is not None else 0
rehab_assessment_status = (
"user_override" if rehab_budget_override is not None
else "not_assessed_in_prescreening"
)
address = deal.get("address") or ""
county = deal.get("county") or ""
state = deal.get("state") or "FL"
case_number = deal.get("case_number")
# Hard rule: court_records mandatorio si distressed o case_number presente
court_records_required = (
bool(case_number) or deal_type in DISTRESSED_DEAL_TYPES
)
_log(f"Pre-screening: {address[:50]} · {deal_type} · ${purchase_price:,.0f}")
if court_records_required:
_log(f" Court records REQUERIDO (case#={bool(case_number)}, distressed={deal_type in DISTRESSED_DEAL_TYPES})")
# ─── STEP 1: Property Appraiser (SOURCE OF TRUTH) ─────────────────────
# BUG FIX 2026-05-15: PA es la fuente primaria para cualquier propiedad.
# NO usar listing data (Zillow) como source — puede estar incompleto/viejo.
# PA da: owner real, year_built REAL, sales_history (detecta flips),
# homestead (occupant vs investor), tax history (renovaciones por jump).
pa_record: Optional[dict] = None
pa_status = "not_run"
pa_supported = False
parcel_id_for_pa = deal.get("parcel_id")
if county and (address or parcel_id_for_pa):
try:
from data_fetchers.property_appraiser import fetch_pa_record, is_pa_supported
pa_supported = is_pa_supported(county, state)
if not pa_supported:
pa_status = "not_implemented_for_county"
_log(f" PA SKIPPED — adapter no implementado para {county} ({state})")
else:
_log(f" PA lookup (county={county}, parcel={parcel_id_for_pa or '?'})...")
pa_record = fetch_pa_record(
county_name=county,
state=state,
address=address,
parcel_id=parcel_id_for_pa,
zip_code=deal.get("zip"),
listing_price=float(purchase_price) if purchase_price else None,
)
if pa_record and not pa_record.get("errors"):
data_sources.append("property_appraiser")
pa_status = "found"
_log(f" owner={pa_record.get('owner_name')!r} "
f"year_built={pa_record.get('year_built')} "
f"homestead={pa_record.get('homestead_active')} "
f"sales={len(pa_record.get('sales_history', []))}")
elif pa_record and pa_record.get("errors"):
pa_status = "error"
errors.append(f"PA errors: {pa_record.get('errors')[:2]}")
_log(f" errors: {pa_record.get('errors')[:1]}")
else:
pa_status = "not_found"
_log(f" no PA data returned")
except Exception as e:
pa_status = "error"
errors.append(f"PA fetch failed: {type(e).__name__}: {e}")
_log(f" exception: {e}")
else:
pa_status = "missing_inputs"
# ─── Court records lookup (ALWAYS if county+address) ──────────────────
liens_detected: list[dict] = []
plaintiff: Optional[str] = None
plaintiff_classified: dict = {}
court_records_status = "not_executed"
court_records_data = {}
if county and address:
try:
from data_fetchers.court_records import fetch_court_records, classify_plaintiff
_log(" Consultando court records...")
cr_result = fetch_court_records(address=address, county_name=county) or {}
court_records_data = cr_result
cr_status = cr_result.get("status", "?")
court_records_status = cr_status
# Extract plaintiff (first lis pendens found)
lis_pendens = cr_result.get("lis_pendens") or []
if lis_pendens:
plaintiff = lis_pendens[0].get("plaintiff")
plaintiff_classified = classify_plaintiff(plaintiff)
_log(f" Plaintiff: {plaintiff} | type={plaintiff_classified.get('type')}")
# Extract liens
liens_raw = cr_result.get("liens_inventory") or {}
items = liens_raw.get("items") or []
for item in items:
liens_detected.append({
"type": item.get("type", "unknown"),
"amount": item.get("amount", 0),
"creditor": item.get("creditor"),
"filed_date": item.get("filed_date"),
})
if items or lis_pendens:
data_sources.append("court_records")
_log(f" Status={cr_status} · {len(liens_detected)} liens · {len(lis_pendens)} lis_pendens")
except Exception as e:
errors.append(f"court_records error: {type(e).__name__}: {e}")
court_records_status = "error"
_log(f" error: {e}")
else:
court_records_status = "missing_inputs"
_log(f" Court records SKIPPED — missing county or address")
# Sanity check: court_records REQUIRED but not run → flag for INSUFFICIENT_DATA.
# OWNER_VERIFIED also counts as successful execution (court records ran, owner
# confirmed via PA, no judicial proceedings found — that's valid output).
_SUCCESSFUL_COURT_STATUSES = {
"CLEAN", "LIS_PENDENS_ACTIVE", "CODE_VIOLATIONS", "TAX_DELINQUENT",
"FORECLOSURE_COMPLETE", "FORECLOSURE_PENDING", "OWNER_VERIFIED",
}
court_records_required_but_missing = (
court_records_required and court_records_status not in _SUCCESSFUL_COURT_STATUSES
)
# ─── Tax assessed (Property Appraiser, FREE) ────────────────────────────
# Honesty: distinguir "county no implementado" de "buscamos y no estaba"
# Broward uses parcel_id (folio) for lookup; other counties may use address
tax_assessed_data = None
tax_assessed_status = "not_run"
parcel_id_for_pa = deal.get("parcel_id")
if county and (address or parcel_id_for_pa):
try:
from data_fetchers.property_value import fetch_tax_assessed, is_tax_assessed_supported
if not is_tax_assessed_supported(county, state):
tax_assessed_status = "not_implemented_for_county"
_log(f" Tax assessed SKIPPED — scraper no implementado para {county} ({state})")
else:
_log(f" Tax assessed (PA, parcel_id={parcel_id_for_pa!r})...")
tax_assessed_data = fetch_tax_assessed(
address=address, county_name=county, state=state,
parcel_id=parcel_id_for_pa,
)
if tax_assessed_data and tax_assessed_data.get("assessed_value"):
data_sources.append("tax_assessed")
tax_assessed_status = "found"
_log(f" assessed=${tax_assessed_data.get('assessed_value', '?')} "
f"market=${tax_assessed_data.get('just_value', '?')} "
f"year={tax_assessed_data.get('year_built', '?')} "
f"sqft={tax_assessed_data.get('sqft', '?')} "
f"owner={tax_assessed_data.get('owner_name', '?')[:30]}")
else:
tax_assessed_status = "not_found"
_log(f" no data returned for parcel_id={parcel_id_for_pa!r}")
except Exception as e:
errors.append(f"tax_assessed error: {type(e).__name__}: {e}")
tax_assessed_status = "error"
_log(f" error: {e}")
else:
tax_assessed_status = "missing_inputs"
# Derive better ARV if tax_assessed found one and user didn't override
if not arv_override and tax_assessed_data and tax_assessed_data.get("assessed_value"):
arv_assessed = float(tax_assessed_data["assessed_value"])
# Use whichever is higher: original arv or tax_assessed-based
arv = max(arv, arv_assessed)
# ─── Comparables — PA sales_history es source primaria (bug fix 2026-05-15)
# ANTES: fetch_zillow_comps (Firecrawl, 1 credit) por cada pre-screening.
# AHORA: PA sales_history del MISMO property + sales recientes del subdivision
# son comps oficiales del county. Solo caemos a Firecrawl si PA no disponible
# Y user habilita ENABLE_FIRECRAWL_COMPS=true.
comps_data: list[dict] = []
comps_estimate: Optional[int] = None
comps_status = "not_run"
if pa_record and pa_record.get("sales_history"):
# SOURCE OF TRUTH path — PA sales history
pa_sales = pa_record["sales_history"] or []
# Use qualified arm's-length sales como comps (excluye quit claims, $100 transfers)
for s in pa_sales[:5]:
price = s.get("price")
if not price or price < 5000:
continue
if str(s.get("qualified", "")).lower().startswith("unqualified"):
continue
comps_data.append({
"address": pa_record.get("site_address") or address,
"sold_price": price,
"sold_date_text": s.get("date"),
"sqft": pa_record.get("sqft_heated") or pa_record.get("sqft_total"),
"price_per_sqft": (
round(price / (pa_record.get("sqft_heated") or 1500), 0)
if pa_record.get("sqft_heated") else None
),
"source": f"{pa_record.get('county', 'County')} PA (sales history of this property)",
"deed_type": s.get("deed_type") or s.get("qualification"),
})
if comps_data:
# Recent qualified sale of THIS property is the strongest comp
comps_estimate = comps_data[0].get("sold_price")
data_sources.append("pa_sales_history")
comps_status = f"found_{len(comps_data)}_pa_sales"
_log(f" Comps via PA sales_history: {len(comps_data)} qualified sales, "
f"most recent ${comps_estimate or 0:,}")
else:
comps_status = "no_qualified_pa_sales"
_log(f" PA sales_history exists but no qualified arm's-length sales")
elif os.getenv("ENABLE_FIRECRAWL_COMPS", "false").lower() == "true":
# FALLBACK path — PA no disponible, usar Firecrawl (paga)
try:
from data_fetchers.property_value import fetch_zillow_comps, estimate_value_from_comps
beds = deal.get("beds") or 3
baths = deal.get("baths") or 2
sqft = deal.get("sqft") or 1500
zip_code = deal.get("zip")
if zip_code:
_log(" Comps FALLBACK Firecrawl Zillow (1 credit, paga)...")
comps_data, comps_errors = fetch_zillow_comps(
zip_code=zip_code,
beds=int(beds), baths=float(baths), sqft=int(sqft),
max_count=3,
)
if comps_data:
comps_estimate, _conf = estimate_value_from_comps(comps_data, int(sqft))
data_sources.append("zillow_comps")
comps_status = f"found_{len(comps_data)}_zillow_comps"
else:
comps_status = "no_comps_found"
else:
comps_status = "missing_zip"
except Exception as e:
errors.append(f"comps error: {type(e).__name__}: {e}")
comps_status = "error"
else:
comps_status = "not_run_no_pa_no_firecrawl"
_log(f" Comps SKIPPED — no PA sales_history y Firecrawl off")
# ─── Price validation — PA es source primaria (bug fix 2026-05-15) ─────
# ANTES: validate_price() llamaba Firecrawl (Zestimate + Redfin) por cada
# pre-screening — gastaba 2 credits incluso cuando PA tenia just_value gratis.
# AHORA: si tenemos PA con just_value, usamos eso como market reference.
# Solo caemos a Firecrawl si PA no implementado para este county Y el user
# explicitamente habilita ENABLE_FIRECRAWL_PRICE_CHECK=true.
price_validation = None
price_status = "not_run"
if purchase_price:
if pa_record and pa_record.get("just_value_current"):
# SOURCE OF TRUTH path — usar PA just_value
pa_market = float(pa_record["just_value_current"])
listing = float(purchase_price)
discrepancy_pct = ((listing - pa_market) / pa_market * 100) if pa_market > 0 else 0
# Status thresholds (mismo que validate_price legacy)
if abs(discrepancy_pct) <= 10:
status = "NORMAL"
elif discrepancy_pct < -25:
status = "CRITICAL_RED_FLAG" # listing >>< market (under-priced, possible distress)
elif discrepancy_pct < -10:
status = "RED_FLAG" # listing under-market 10-25%
elif discrepancy_pct > 25:
status = "OVERPRICED_SEVERE"
else:
status = "OVERPRICED_MODERATE"
price_validation = {
"status": status,
"market_estimate": int(pa_market),
"listing_price": int(listing),
"signed_max_discrepancy_pct": round(discrepancy_pct, 1),
"sources_used": [f"{pa_record.get('county', 'County')} Property Appraiser (just_value)"],
"confidence": "high", # PA es oficial
"method": "pa_just_value",
}
data_sources.append("price_validator")
price_status = status
_log(f" Price validation via PA just_value: status={status} "
f"market=${pa_market:,.0f} listing=${listing:,.0f} ({discrepancy_pct:+.1f}%)")
elif os.getenv("ENABLE_FIRECRAWL_PRICE_CHECK", "false").lower() == "true":
# FALLBACK path — PA no disponible, usar Firecrawl (paga)
try:
from data_fetchers.price_validator import validate_price
_log(" Price validation FALLBACK (Firecrawl Zestimate/Redfin, paga)...")
price_validation = validate_price(
address=address,
listing_price=float(purchase_price),
tax_assessed_value=tax_assessed_data.get("assessed_value") if tax_assessed_data else None,
use_firecrawl=True,
)
data_sources.append("price_validator")
price_status = price_validation.get("status", "?")
_log(f" status={price_status}")
except Exception as e:
errors.append(f"price_validator error: {type(e).__name__}: {e}")
price_status = "error"
else:
price_status = "not_run_no_pa_no_firecrawl"
_log(f" Price validation SKIPPED — no PA data y Firecrawl off")
# If foreclosure with final_judgment_amount but no detailed liens, treat it as a primary mortgage lien
final_judgment = deal.get("final_judgment_amount")
if deal_type in ("foreclosure", "auction") and final_judgment and not liens_detected:
liens_detected.append({
"type": LIEN_MORTGAGE_PRIMARY,
"amount": int(final_judgment),
"creditor": plaintiff or "(unknown plaintiff)",
"filed_date": None,
})
data_sources.append("final_judgment_amount")
# ─── Calculate surviving debt ──────────────────────────────────────────
surviving_debt, liens_enriched = calculate_surviving_debt(
liens_detected=liens_detected,
deal_type=deal_type,
plaintiff=plaintiff,
)
effective_cost = int(purchase_price + surviving_debt + rehab_budget)
margin_pct = round(((arv - effective_cost) / arv * 100), 1) if arv > 0 else 0.0
_log(f" Surviving debt: ${surviving_debt:,} · effective ${effective_cost:,} · margin {margin_pct}%")
# ─── Build data_sources_status to inform the LLM ───────────────────────
# tax_assessed_status puede ser: found | not_found | not_implemented_for_county
# | missing_inputs | error | not_run
# property_appraiser tiene el mismo set de estados.
data_sources_status = {
"property_appraiser": pa_status,
"price_validator": price_status,
"court_records": court_records_status,
"comps": comps_status,
"tax_assessed": tax_assessed_status,
}
# ─── Call LienPositionAnalyzer for verdict + reasoning ─────────────────
_log(" Llamando LienPositionAnalyzer...")
verdict_data = _call_lien_analyzer(
deal_type=deal_type,
purchase_price=int(purchase_price),
arv=arv,
rehab_budget=rehab_budget,
rehab_assessment_status=rehab_assessment_status,
pa_record=pa_record,
liens_detected=liens_enriched,
plaintiff_info={
"plaintiff_name": plaintiff,
"plaintiff_type": plaintiff_classified.get("type") if plaintiff_classified else None,
"plaintiff_category": plaintiff_classified.get("category") if plaintiff_classified else None,
"plaintiff_note": plaintiff_classified.get("note") if plaintiff_classified else None,
},
case_number=case_number,
court_records_required=court_records_required,
court_records_required_but_missing=court_records_required_but_missing,
data_sources_status=data_sources_status,
pre_computed={
"total_surviving_debt": surviving_debt,
"effective_acquisition_cost": effective_cost,
"margin_vs_arv": margin_pct / 100,
},
# Enriquecido con data publica para que el LLM razone con hechos
tax_assessed_data=tax_assessed_data,
price_validation=price_validation,
comps_data=comps_data,
comps_estimate=comps_estimate,
)
if verdict_data.get("_error"):
errors.append(verdict_data["_error"])
# ─── OWNER CLASSIFIER + REO SIGNAL (bug fix 2026-05-15) ──────────────
# Detecta lender-owned properties para flag REO direct outreach opportunity.
owner_classification = None
reo_signal = None
if pa_record and pa_record.get("owner_name"):
try:
from data_fetchers.owner_classifier import classify_owner, build_reo_signal
owner_classification = classify_owner(
pa_record.get("owner_name"),
co_owners=pa_record.get("co_owners") or [],
)
reo_signal = build_reo_signal(
owner_classification=owner_classification,
just_value=pa_record.get("just_value_current"),
assessed_value=pa_record.get("assessed_value_current"),
listing_price=float(purchase_price) if purchase_price else None,
taxes_paid_last=pa_record.get("taxes_paid_last"),
mailing_address=pa_record.get("mailing_address"),
)
if reo_signal and reo_signal.get("is_reo_opportunity"):
_log(f" REO OPPORTUNITY: {owner_classification.get('type')} "
f"owner detected. Suggested offer "
f"${reo_signal.get('suggested_offer_low'):,}-"
f"${reo_signal.get('suggested_offer_high'):,}")
except Exception as e:
errors.append(f"owner_classifier error: {type(e).__name__}: {e}")
# ─── FINANCIAL ANALYSIS (bug fix 2026-05-15) ────────────────────────────
# Compute max profitable offer, multi-price payment table, live-in scenario
# with DTI evaluation if income provided.
# HOA: priority user override > deal.hoa_monthly > 0
hoa_resolved = hoa_monthly_override if hoa_monthly_override is not None else (deal.get("hoa_monthly") or 0)
financial_analysis = _build_financial_analysis(
purchase_price=float(purchase_price) if purchase_price else 0,
arv=float(arv) if arv else 0,
pa_record=pa_record,
down_payment=down_payment,
monthly_income=monthly_income,
rent_room_monthly=rent_room_monthly,
other_monthly_debts=other_monthly_debts,
loan_type=loan_type,
hoa_monthly=float(hoa_resolved or 0),
rehab_budget_known=rehab_budget if rehab_assessment_status == "user_override" else None,
)
_log(f" Financial: max_offer=${financial_analysis['max_profitable_offer']['max_offer']:,.0f} "
f"(target {int(financial_analysis['max_profitable_offer']['target_margin_pct'])}% margin). "
f"DTI verdict: {financial_analysis['live_in_scenario'].get('dti_evaluation',{}).get('verdict','N/A') if financial_analysis['live_in_scenario'].get('dti_evaluation') else 'N/A (no income)'}.")
elapsed = time.perf_counter() - t0
# If court_records was required but couldn't run, override LLM verdict to
# INSUFFICIENT_DATA (the LLM should also do this per its prompt, but we
# enforce it deterministically here as backstop).
final_verdict = verdict_data.get("verdict") or _fallback_verdict(margin_pct)
if court_records_required_but_missing and final_verdict != "INSUFFICIENT_DATA":
final_verdict = "INSUFFICIENT_DATA"
verdict_data["reasoning"] = (
f"Court records search no se pudo ejecutar para {county} County "
f"(status={court_records_status}). El deal es distressed o tiene "
f"case_number activo, por lo que se requiere verificacion judicial "
f"antes de emitir veredicto responsable."
)
verdict_data["score"] = 0
result_dict = {
"verdict": final_verdict,
"score": verdict_data.get("score") if verdict_data.get("score") is not None else _fallback_score(margin_pct),
"reasoning": verdict_data.get("reasoning") or _fallback_reasoning(margin_pct, surviving_debt),
"purchase_price": int(purchase_price),
"arv": arv,
"rehab_budget": rehab_budget,
"rehab_assessment_status": rehab_assessment_status, # NEW: honest label
"surviving_debt_total": surviving_debt,
"effective_cost": effective_cost,
"margin_pct": margin_pct,
"red_flags": verdict_data.get("red_flags") or [],
"liens_detected": liens_enriched,
"plaintiff": plaintiff,
"players": _ensure_players(verdict_data.get("players"), plaintiff, plaintiff_classified),
# Rich public data (no inventado por LLM)
"property_appraiser": pa_record, # SOURCE OF TRUTH
"owner_classification": owner_classification, # type (BANK/GSE/IND/etc)
"reo_signal": reo_signal, # is_reo_opportunity + offer range + justification
"financial_analysis": financial_analysis, # multi-price + live-in + DTI + max offer
"tax_assessed": tax_assessed_data,
"price_validation": price_validation,
"comps": comps_data,
"comps_estimate": comps_estimate,
"court_records_raw": court_records_data,
"data_sources_used": data_sources,
"data_sources_status": data_sources_status,
"court_records_required": court_records_required,
"court_records_status": court_records_status,
"elapsed_seconds": round(elapsed, 1),
"errors": errors,
}
# Persist to property folder (idempotent — saves timestamped pre_screening JSON)
property_folder = _persist_to_property_folder(deal, result_dict)
if property_folder:
result_dict["property_folder"] = property_folder
_log(f" Saved to: {property_folder}")
else:
result_dict["property_folder"] = None
return result_dict
def _ensure_players(players_from_llm: Optional[dict], plaintiff: Optional[str],
classified: dict) -> dict:
"""Asegura que players tenga una implication util.
Si el LLM devolvio empty/missing fields, usar fallback determinista.
"""
if not players_from_llm:
return _build_default_players(plaintiff, classified)
implication = (players_from_llm.get("implication") or "").strip()
if not implication:
fallback = _build_default_players(plaintiff, classified)
players_from_llm["implication"] = fallback["implication"]
if not players_from_llm.get("plaintiff_name") and plaintiff:
players_from_llm["plaintiff_name"] = plaintiff
if not players_from_llm.get("plaintiff_type") and classified.get("type"):
players_from_llm["plaintiff_type"] = classified["type"]
return players_from_llm
def _build_default_players(plaintiff: Optional[str], classified: dict) -> dict:
"""Fallback players dict si el LLM no lo generó.
Si no hay plaintiff: explica las 3 posibilidades (MLS legit / auction off
de court / case prematuro) para que el usuario sepa qué considerar.
"""
if not plaintiff:
return {
"plaintiff_name": None,
"plaintiff_type": None,
"implication": (
"No se identificó plaintiff en court records del county. "
"Tres posibilidades: "
"(a) deal es MLS legítimo, no hay proceso judicial; "
"(b) deal es auction privada (auction.com / hubzu / bank-owned), "
"no court-supervised — investigar la fuente Zillow; "
"(c) lis pendens recién filed y aún no indexado — re-correr en 48h. "
"Si Zillow muestra 'Foreclosure Auction' considerá (b) como más probable."
),
}
return {
"plaintiff_name": plaintiff,
"plaintiff_type": classified.get("type") if classified else None,
"implication": (classified.get("note") if classified else None)
or "Ver clasificación de plaintiff.",
}
def _call_lien_analyzer(
*,
deal_type: str,
purchase_price: int,
arv: int,
rehab_budget: int,
rehab_assessment_status: str = "not_assessed_in_prescreening",
pa_record: Optional[dict] = None,
liens_detected: list[dict],
plaintiff_info: dict,
case_number: Optional[str],
court_records_required: bool,
court_records_required_but_missing: bool,
data_sources_status: dict,
pre_computed: dict,
tax_assessed_data: Optional[dict] = None,
price_validation: Optional[dict] = None,
comps_data: Optional[list] = None,
comps_estimate: Optional[int] = None,
) -> dict:
"""Llama al modelo Ollama LienPositionAnalyzer. JSON output esperado."""
try:
import ollama
import concurrent.futures
except ImportError:
return {"_error": "ollama package not available"}
# Annotate data_sources_status with semantic flags so the LLM doesn't
# mistake "ran without findings" for "missing source" or "not implemented".
annotated_sources = {}
for k, v in data_sources_status.items():
if v in ("NOT_IMPLEMENTED", "not_implemented_for_county"):
annotated_sources[k] = {
"status": v,
"outcome": "not_supported_for_county",
"interpretation": (
"Source is PUBLICLY AVAILABLE for this county but our scraper "
"does NOT YET cover it. Be honest: do NOT claim data was searched "
"and not found. State explicitly that this source has not been "
"checked because our adapter is pending. List as red_flag."
),
}
elif v in ("UNKNOWN", "no_comps_found", "no_results", "INCONCLUSIVE"):
annotated_sources[k] = {"status": v, "outcome": "ran_inconclusive",
"interpretation": "Source DID run but produced no actionable result. Do NOT say 'falta validación de X' — say 'X result inconclusive'."}
elif v in ("not_run", "error", "missing_inputs", "not_found", "missing_zip"):
annotated_sources[k] = {"status": v, "outcome": "did_not_run",
"interpretation": "Source DID NOT execute. You may say data is missing for this source."}
else:
annotated_sources[k] = {"status": v, "outcome": "ran_successfully",
"interpretation": "Source executed and produced a result. Use the data from the corresponding section in your reasoning."}
# PA data — SOURCE OF TRUTH para el LLM
pa_summary = None
if pa_record:
rs = pa_record.get("renovation_signal") or {}
pa_summary = {
"owner_name": pa_record.get("owner_name"),
"parcel_id": pa_record.get("parcel_id"),
"year_built": pa_record.get("year_built"),
"effective_year_built": pa_record.get("effective_year_built"),
"bedrooms": pa_record.get("bedrooms"),
"baths": pa_record.get("baths"),
"sqft_heated": pa_record.get("sqft_heated"),
"homestead_active": pa_record.get("homestead_active"),
"homestead_amount": pa_record.get("homestead_amount"),
"owner_address_mismatch": pa_record.get("owner_address_mismatch"),
"just_value_current": pa_record.get("just_value_current"),
"assessed_value_current": pa_record.get("assessed_value_current"),
"just_value_last": pa_record.get("just_value_last"),
"assessed_value_last": pa_record.get("assessed_value_last"),
"use_code": pa_record.get("use_code"),
"use_description": pa_record.get("use_description"),
"zoning": pa_record.get("zoning"),
"subdivision": pa_record.get("subdivision"),
"roof_type": pa_record.get("roof_type"),
"exterior_wall": pa_record.get("exterior_wall"),
"most_recent_qualified_sale": pa_record.get("most_recent_qualified_sale"),
"renovation_signal": {
"is_flip_pattern": rs.get("is_flip_pattern"),
"evidence": rs.get("evidence"),
"value_increase_pct": rs.get("value_increase_pct"),
"months_between": rs.get("months_between"),
},
"sales_history_count": len(pa_record.get("sales_history") or []),
"sales_history_top3": (pa_record.get("sales_history") or [])[:3],
"source": pa_record.get("source"),
}
user_payload = {
"deal_type": deal_type,
"purchase_price": purchase_price,
"arv": arv,
"rehab_budget": rehab_budget,
"rehab_assessment_status": rehab_assessment_status, # 'not_assessed_in_prescreening' | 'user_override'
"property_appraiser": pa_summary, # SOURCE OF TRUTH
"liens_detected": [
{k: v for k, v in lien.items() if k in
("type", "amount", "creditor", "notes", "survives", "survival_reason", "amount_post_cap")}
for lien in liens_detected
],
"plaintiff_info": plaintiff_info,
"case_number": case_number,
"court_records_required": court_records_required,
"court_records_required_but_missing": court_records_required_but_missing,
"data_sources_status": data_sources_status,
"data_sources_annotated": annotated_sources,
"pre_computed": pre_computed,
# Rich public data — el LLM puede usar para razonar con hechos.
# Para counties con full PA adapter (Broward), pasamos data extendida:
# mailing_address, sales_history, taxes_paid, homestead_active — el LLM
# puede detectar oportunidades como REO direct outreach, owner-occupied
# vs absentee, undervalued purchases via sales_history vs market, etc.
"tax_assessed": (
{k: tax_assessed_data.get(k) for k in
("assessed_value", "market_value", "just_value", "year_built", "sqft",
"beds", "baths", "owner_name", "source",
"mailing_address", "situs_address", "neighborhood", "use_code",
"legal_description", "homestead_active",
"taxes_paid_last_year", "tax_year_last",
"sales_history", # list of {date, type, price, book_page_or_cin}
)}
if tax_assessed_data else None
),
"price_validation": (
{k: price_validation.get(k) for k in
("status", "market_estimate", "signed_max_discrepancy_pct",
"sources_used", "confidence")}
if price_validation else None
),
"comps_sample": (
[{k: c.get(k) for k in ("sold_price", "sqft", "price_per_sqft", "address", "sold_date_text")}
for c in comps_data[:3]]
if comps_data else None
),
"comps_estimate": comps_estimate,
}
prompt = (
"Analizá la siguiente situación de liens y devolvé el veredicto en JSON estricto.\n\n"
f"```json\n{json.dumps(user_payload, indent=2, default=str)}\n```\n"
)
try:
resp = ollama.chat(
model="LienPositionAnalyzer",
messages=[{"role": "user", "content": prompt}],
format="json",
options={"temperature": 0.2, "num_ctx": 8192},
)
content = resp["message"]["content"]
data = json.loads(content)
return data
except Exception as e:
return {"_error": f"LienPositionAnalyzer error: {type(e).__name__}: {e}"}
def _fallback_verdict(margin_pct: float) -> str:
"""Si el agente falla, dar un veredicto basado solo en el margen pre-calculado."""
if margin_pct < 15:
return "NO-GO"
elif margin_pct < 30:
return "MAYBE"
return "GO"
def _fallback_score(margin_pct: float) -> int:
if margin_pct < 0:
return 1
if margin_pct < 15:
return 3
if margin_pct < 25:
return 5
if margin_pct < 35:
return 7
return 9
def _fallback_reasoning(margin_pct: float, surviving_debt: int) -> str:
return (
f"Margen calculado {margin_pct:.1f}% sobre ARV. "
f"Surviving debt estimado: ${surviving_debt:,}. "
f"Análisis profundo recomendado (LienPositionAnalyzer no disponible)."
)
# ════════════════════════════════════════════════════════════════════════════
# Financial Analysis Builder (bug fix 2026-05-15)
# ════════════════════════════════════════════════════════════════════════════
def _build_financial_analysis(
*,
purchase_price: float,
arv: float,
pa_record: Optional[dict] = None,
down_payment: Optional[float] = None,
monthly_income: Optional[float] = None,
rent_room_monthly: Optional[float] = None,
other_monthly_debts: Optional[float] = None,
loan_type: str = "conventional_oo",
hoa_monthly: float = 0,
rehab_budget_known: Optional[float] = None,
) -> dict:
"""Comprehensive financial analysis for pre-screening output.
Combines:
- Max profitable offer (with target investor margin)
- Multi-price payment table (max_offer / midpoint / asking)
- Live-in scenario with FHA/Conventional rates
- DTI evaluation (if monthly_income provided)
- Rent-a-room (house-hack) scenarios
PA record used to extract real tax_annual (from assessed_value).
"""
from finance_calculator import (
calculate_max_profitable_offer, calculate_payment_table,
calculate_live_in_scenario,
)
# Defaults if not provided
if down_payment is None or down_payment <= 0:
# Default: 5% of asking (~conventional OO minimum)
down_payment = purchase_price * 0.05 if purchase_price > 0 else 0
if rent_room_monthly is None:
rent_room_monthly = 0
if other_monthly_debts is None:
other_monthly_debts = 0
# Pull real tax/insurance from PA if available
tax_annual = 0.0
insurance_annual = 0.0
if pa_record:
# Use just_value_current as basis. Tax ~= 1.3% of just value in FL average.
# (Real tax_breakdown comes from full-county adapter; for now estimate).
just_value = pa_record.get("just_value_current") or 0
if just_value:
tax_annual = just_value * 0.013
# Insurance: use FL_INSURANCE_TIERS heuristic
from finance_calculator import FL_INSURANCE_TIERS
for cap, premium in FL_INSURANCE_TIERS:
if just_value < cap:
insurance_annual = premium
break
# Rehab estimate — use user override if available, else assume modest 5% of arv
# (pre-screening doesn't have photo inspector). If PA shows flip-in-progress,
# rehab likely already done by seller (we may not need to estimate as buyer).
if rehab_budget_known is not None:
rehab_estimate = rehab_budget_known
else:
# If listing is for renovated property (flip-in-progress), buyer rehab = 0
flip_in_progress = False
if pa_record:
rs = pa_record.get("renovation_signal") or {}
flip_in_progress = bool(rs.get("is_flip_in_progress"))
if flip_in_progress:
rehab_estimate = 0 # seller already renovated
else:
rehab_estimate = arv * 0.05 if arv else 0 # conservative 5% placeholder
# ─── Max profitable offer (investor view) ──────────────────────────────
max_offer_data = calculate_max_profitable_offer(
arv=arv or purchase_price,
rehab_estimate=rehab_estimate,
)
# ─── Multi-price payment table ─────────────────────────────────────────
payment_table = calculate_payment_table(
asking_price=purchase_price,
max_offer=max_offer_data["max_offer"],
down_payment=down_payment,
tax_annual=tax_annual,
insurance_annual=insurance_annual,
hoa_monthly=hoa_monthly,
)
# ─── Live-in scenario (FHA/Conventional OO) ────────────────────────────
live_in = calculate_live_in_scenario(
purchase_price=purchase_price,
down_payment=down_payment,
loan_type=loan_type,
years=30,
tax_annual=tax_annual,
insurance_annual=insurance_annual,
hoa_monthly=hoa_monthly,
rent_room_monthly=rent_room_monthly,
monthly_income=monthly_income or 0,
other_monthly_debts=other_monthly_debts,
)
# ─── Recommendation text ────────────────────────────────────────────────
rec_offer = max_offer_data["max_offer"]
asking = purchase_price
if asking > 0 and rec_offer > 0:
rec_pct_below_asking = (1 - rec_offer / asking) * 100
midpoint = (asking + rec_offer) / 2
recommendation = (
f"Oferta recomendada: ${rec_offer:,.0f} ({rec_pct_below_asking:.0f}% bajo asking). "
f"Midpoint negociacion: ${midpoint:,.0f}. Asking: ${asking:,.0f}. "
f"Justificacion: {max_offer_data['justification']}"
)
else:
recommendation = "Sin asking price valido; recomendacion no calculable."
return {
"inputs": {
"asking_price": purchase_price,
"arv": arv,
"down_payment": down_payment,
"monthly_income": monthly_income,
"rent_room_monthly": rent_room_monthly,
"other_monthly_debts": other_monthly_debts,
"loan_type": loan_type,
"hoa_monthly": hoa_monthly,
"rehab_estimate_used": rehab_estimate,
"tax_annual_from_pa": tax_annual,
"insurance_annual_estimated": insurance_annual,
},
"max_profitable_offer": max_offer_data,
"payment_table": payment_table, # [{label, price, piti_monthly, ...}, ...]
"live_in_scenario": live_in, # incluye dti_evaluation si income provisto
"recommendation": recommendation,
}
# ════════════════════════════════════════════════════════════════════════════
# Persistence to property folder
# ════════════════════════════════════════════════════════════════════════════
def _persist_to_property_folder(deal: dict, result: dict) -> Optional[str]:
"""Save pre-screening result to properties/{state}/{county}/{type}/{id}/ folder.
Each pre-screening run gets a timestamped JSON. Updates .meta.json with
last_dd_run_at and last_dd_kind = 'pre_dd'.
Returns folder path (str) for inclusion in result, or None if persist failed.
"""
try:
from properties_store import ensure_property_folder, save_json, write_meta
from datetime import datetime, timezone
folder = ensure_property_folder(deal)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
report_path = folder / "due_diligence" / f"pre_screening_{stamp}.json"
save_json(report_path, result)
write_meta(folder, last_dd_run_at=datetime.now(timezone.utc).isoformat(),
last_dd_kind="pre_dd", last_verdict=result.get("verdict"),
last_score=result.get("score"))
return str(folder)
except Exception as e:
# Don't fail pre-screening if persist fails
return None