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

1452 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
prompt_templates.py - Construye el mensaje del usuario para cada agente.
Los agentes ya tienen su system prompt completo en el Modelfile.
Aca solo armamos el mensaje user-side con los datos concretos del deal.
PRINCIPIO ANTI-ALUCINACION (issue detectado en smoke test #2):
Si un campo opcional viene None, el template lo marca explicitamente como
"no provisto - NO inventes un valor". Esto previene que el modelo se invente
FICOs, capitales, fechas, etc. que el usuario nunca dio.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from orchestrator import DealInputs, BuyerProfile, AnalysisResult
# ---------------------------------------------------------------------------
# Helpers de formato
# ---------------------------------------------------------------------------
def _money(x: Optional[float]) -> str:
if x is None:
return "(no provisto)"
return f"${x:,.0f}"
def _none_marker(value) -> str:
if value is None or value == "":
return "(no provisto - NO inventes un valor)"
return str(value)
PROFILE_DESCRIPTIONS = {
"A": "USA citizen/resident con buen credito (FICO 700+) y W2 o LLC con seasoning",
"B": "USA citizen/resident con FICO bajo (<680) o sin seasoning",
"C": "Foreign national (no SSN, no US credit, ITIN holder, visa B1/B2, o residente extranjero)",
"D": "Inversionista experimentado con portfolio existente",
}
EMAIL_TYPES = {
1: "FIRST CONTACT con DSCR LENDER (cold)",
2: "FIRST CONTACT con HARD MONEY LENDER (cold)",
3: "SELLER FINANCE OFFER al SELLER",
4: "CASH OFFER en WHOLESALE / DIRECT TO SELLER",
5: "PRIVATE MONEY PITCH a friend/family/HNW",
6: "FOLLOW-UP a lender que no respondio",
7: "INTRO email a un AGENT/WHOLESALER para buyers list",
8: "CONNECT WITH OTHER INVESTOR en REIA / BiggerPockets",
9: "THANK YOU despues de llamada con lender/seller",
10: "NEGOTIATION email (counter offer, asking concession)",
}
# ---------------------------------------------------------------------------
# Wave 1.5A: Court Records block (owner_name + RE# + lis pendens)
# ---------------------------------------------------------------------------
def _format_court_records_block(verified_data: Optional[dict]) -> str:
"""Genera bloque de court records para inyectar en prompts.
Si court_records confirma propiedad existe en clerk records:
- owner_name (clave para inferir distressed)
- re_number (parcel ID oficial)
- tax_assessed_value (oficial del clerk, mas confiable que estimaciones)
- year_built_official
- lis pendens (si Acclaim scraper logra detectarlos)
Returns string vacio si court_records no esta disponible o esta DISABLED.
"""
if not verified_data or not isinstance(verified_data, dict):
return ""
cr = verified_data.get("court_records") or {}
if not cr or cr.get("status") in (None, "DISABLED", "UNKNOWN"):
return ""
status = cr.get("status")
owner = cr.get("owner_name")
re_num = cr.get("re_number")
tax_av = cr.get("tax_assessed_value")
yb_official = cr.get("year_built_official")
last_sale = cr.get("last_sale_date")
lp_count = cr.get("lis_pendens_count", 0)
lp_list = cr.get("lis_pendens") or []
county = cr.get("county", "?")
sources = cr.get("sources_used") or []
lines = [f"\n=== COURT RECORDS ({county} County clerk) — DATOS OFICIALES ===\n"]
if status == "NOT_IMPLEMENTED":
return f"\n=== COURT RECORDS ({county}) ===\nScraper no implementado para {county} en esta version. Lookup manual: {cr.get('clerk_url', '?')}\n"
if owner:
lines.append(f"- **Owner oficial (Property Appraiser):** `{owner}`")
# Hints heuristicos para el LLM
owner_upper = owner.upper()
if any(kw in owner_upper for kw in ["LLC", "INC", "CORP", "PARTNERSHIP"]):
lines.append(" * El owner es una ENTITY (LLC/Corp/Partnership) — comun en investors, "
"flippers, holding companies. Puede indicar distressed sale por LLC dissolution.")
elif any(kw in owner_upper for kw in ["TRUST", "ESTATE OF", "TR ", " TR"]):
lines.append(" * El owner es un TRUST/ESTATE — frecuentemente indica probate sale "
"(heirs liquidando rapido) o estate planning distress.")
elif "CITY OF" in owner_upper or "COUNTY OF" in owner_upper or "STATE OF" in owner_upper:
lines.append(" * El owner es GOBIERNO (municipalidad/condado/estado). Tax foreclosure "
"tax deed sale likely. O propiedad publica no-vendible.")
elif "BANK" in owner_upper or "MORTGAGE" in owner_upper or "REO" in owner_upper:
lines.append(" * El owner es un LENDER/BANCO — propiedad **REO confirmada** (post-foreclosure). "
"Bank-owned. Usar formula MAB en vez de Strike/Stretch. Cash-only typical.")
# else: persona fisica normal — sin hints especiales
if re_num:
lines.append(f"- **RE Number (parcel ID):** `{re_num}`")
if yb_official:
lines.append(f"- **Year built (oficial):** {yb_official}")
if tax_av:
lines.append(f"- **Tax assessed value (oficial):** ${tax_av:,.0f}")
if last_sale:
lines.append(f"- **Last sale date:** {last_sale}")
if status == "LIS_PENDENS_ACTIVE":
lines.append(f"\n**LIS PENDENS ACTIVO — {lp_count} caso(s):**")
for c in lp_list[:5]:
lines.append(
f" - Filed `{c.get('filing_date', '?')}` | "
f"Instrument `{c.get('instrument_number', '?')}` | "
f"Doc: `{c.get('doc_type', '?')}`"
)
# Wave 1.5A v1.2: PLAYERS — who is suing
plaintiff = cr.get("plaintiff") or {}
if plaintiff and plaintiff.get("name"):
lines.append("\n**PLAYERS — quien demanda:**")
lines.append(f" - Plaintiff: `{plaintiff['name']}`")
lines.append(f" - Tipo: **{plaintiff.get('type', '?')}** (categoria: {plaintiff.get('category', '?')})")
if plaintiff.get("is_original_loan_holder") is True:
lines.append(" - El plaintiff es el originador del loan (no es servicer ni securitization).")
elif plaintiff.get("is_original_loan_holder") is False:
lines.append(" - El plaintiff NO es el originador (es servicer / GSE / trustee / tax authority).")
if plaintiff.get("note"):
lines.append(f" - Implicancia: {plaintiff['note']}")
# Wave 1.5A v1.2: LIENS INVENTORY
all_liens = cr.get("all_liens") or []
total_surviving = cr.get("total_surviving_debt", 0) or 0
liens_status = cr.get("liens_detail_status")
investor_warning = cr.get("investor_warning")
lines.append("\n**LIENS INVENTORY contra la propiedad:**")
if all_liens:
lines.append("| # | Holder | Type | Amount | Filed | Survives? |")
lines.append("|---|---|---|---|---|---|")
for i, lien in enumerate(all_liens[:12], 1):
survives_short = {
"SURVIVES": "HEREDADO",
"EXTINGUISHED": "Extinguido",
"EXTINGUISHED_BY_THIS_ACTION": "(por esta accion)",
"UNCERTAIN": "Incierto",
}.get(lien.get("survives_foreclosure", ""), lien.get("survives_foreclosure", "?"))
lines.append(
f"| {i} | {lien.get('holder', '?')[:30]} | {lien.get('type', '?')} | "
f"${(lien.get('amount') or 0):,.0f} | {lien.get('filed', '?')} | {survives_short} |"
)
lines.append(f"\n**Total surviving debt (heredado por buyer post-auction):** **${total_surviving:,.0f}**")
if investor_warning:
lines.append(f"\n**INVESTOR WARNING:** {investor_warning}")
elif liens_status == "PENDING_V1_1":
lines.append(
"\n**Liens detail no disponible automaticamente en este release.** "
"Wave 1.5A v1.1 (Acclaim Land Records scraper) deferred a Phase 3.5."
)
if investor_warning:
lines.append(f"\n{investor_warning}")
lines.append(
"\n**INSTRUCCION CRITICA AL AGENTE:** lis pendens activo significa foreclosure "
"judicial en curso. NO procede analizar como deal MLS. **Recalcular como MAB** "
"(auction formula). Si hay liens heredables (IRS, municipal, HOA, senior mortgages), "
"el effective_MAB es MAB_original - surviving_debt. **Mencionar explicitamente** "
"quien demanda (plaintiff) y por que importa cada lien heredable. "
"Recomendar **title search profesional ($300-500)** ANTES de cualquier bid — "
"no hay forma 100% confiable de detectar TODOS los liens sin esa investigacion."
)
elif status == "OWNER_VERIFIED":
# v1: lis_pendens scraper deferred. Hay owner pero no podemos confirmar foreclosure.
# Mostrar warning estandar de title search.
lines.append("\n*Lis pendens lookup automatico no disponible en este release "
"(v1.1 pendiente). Status: OWNER_VERIFIED — propiedad existe en Duval PA. "
"Si el owner pattern arriba sugiere distressed, considera buscar manualmente.*")
# Wave 1.5A v1.2: si suspicious_low_listing + OWNER_VERIFIED, surface warning sobre liens
investor_warning = cr.get("investor_warning")
if investor_warning:
lines.append(f"\n**Sobre liens heredables:** {investor_warning}")
lines.append(f"\nFuentes: {', '.join(sources) if sources else 'limitadas'}")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Bug 2: Price Discrepancy Red Flag block
# ---------------------------------------------------------------------------
def _format_price_validation_warning(price_validation: Optional[dict]) -> str:
"""Genera el bloque de RED FLAG que se inyecta a cada prompt.
Si status es CRITICAL_RED_FLAG: bloque grande con razones probables +
instruccion al agente para mencionarlo en su seccion correspondiente.
Si WARNING: aviso breve.
Si UNKNOWN + suspicious_low_listing: bloque "DISTRESSED HYPOTHESIS" (Bug 6).
Si NORMAL: cadena vacia (sin ruido).
"""
if not price_validation or not isinstance(price_validation, dict):
return ""
status = price_validation.get("status")
# Bug 6: UNKNOWN con listing suspicious-low debe inyectar hipotesis distressed
suspicious_unknown = (
status == "UNKNOWN" and price_validation.get("suspicious_low_listing", False)
)
# Wave 1.5A: CONFIRMED_DISTRESSED es el nivel mas alto, court records confirmaron
if status not in ("CRITICAL_RED_FLAG", "WARNING", "CONFIRMED_DISTRESSED") and not suspicious_unknown:
return ""
# Wave 1.5A: rama CONFIRMED_DISTRESSED — court records confirman foreclosure
if status == "CONFIRMED_DISTRESSED":
court = price_validation.get("court_records") or {}
listing = price_validation.get("listing_price")
owner = court.get("owner_name", "?")
lp_count = court.get("lis_pendens_count", 0)
lp_list = court.get("lis_pendens", [])
recommendation = price_validation.get("recommendation", "")
mismatch = price_validation.get("deal_type_mismatch", False)
lp_cases_block = ""
if lp_list:
lp_cases_block = "\nCasos lis pendens detectados:\n" + "\n".join(
f" - filing_date: {c.get('filing_date', '?')} | "
f"instrument: {c.get('instrument_number', '?')} | "
f"doc_type: {c.get('doc_type', '?')}"
for c in lp_list[:5]
)
# Wave 1.5A v1.2: PLAYERS & LIENS block
plaintiff = court.get("plaintiff") or {}
players_block = ""
if plaintiff and plaintiff.get("name"):
players_block = (
f"\n**PLAYER (plaintiff):** `{plaintiff['name']}` "
f"({plaintiff.get('type', '?')}, {plaintiff.get('category', '?')})\n"
)
if plaintiff.get("note"):
players_block += f" - {plaintiff['note']}\n"
all_liens = court.get("all_liens") or []
total_surviving = court.get("total_surviving_debt", 0) or 0
liens_detail_status = court.get("liens_detail_status")
liens_block = ""
if all_liens:
liens_block = "\n**LIENS HEREDABLES detectados:**\n"
for i, lien in enumerate(all_liens[:10], 1):
survives = lien.get("survives_foreclosure", "?")
if survives in ("SURVIVES", "UNCERTAIN"):
flag = "SOBREVIVE" if survives == "SURVIVES" else "INCIERTO"
else:
flag = "Extinguido"
liens_block += (
f" {i}. {lien.get('holder', '?')[:40]} | {lien.get('type', '?')} | "
f"${(lien.get('amount') or 0):,.0f} | {flag}\n"
)
liens_block += f"\n**Total surviving debt** (heredado): **${total_surviving:,.0f}**\n"
liens_block += "**Effective MAB = MAB_original - surviving_debt.** El bid maximo baja proporcionalmente.\n"
elif liens_detail_status == "PENDING_V1_1":
liens_block = (
"\n**LIENS DETAIL no disponible** (Wave 1.5A v1.1 pendiente). "
"Antes de bid: title search profesional ($300-500). Listing puede tener "
"$30K-$100K en deudas heredables NO visibles aqui.\n"
)
return f"""
═══════════════════════════════════════════════════════════════
CONFIRMED DISTRESSED — LIS PENDENS ACTIVO EN COURT RECORDS
═══════════════════════════════════════════════════════════════
Property: ${listing:,.0f} | Owner: {owner} | Cases activos: {lp_count}
ESTO NO ES HIPOTESIS — es hecho judicial publicamente verificable.
Source: Duval County Clerk Records (or.duvalclerk.com)
{lp_cases_block}{players_block}{liens_block}
{recommendation}
INSTRUCCION OBLIGATORIA AL AGENTE:
- Trata este deal como FORECLOSURE confirmado. NO como MLS normal.
- Si el deal_type del usuario es 'mls' pero court records dicen foreclosure
(deal_type_mismatch={mismatch}), incluir alerta visible en tu output.
- Recalcular bid como **effective_MAB** (auction formula menos surviving debt).
- Mencionar QUIEN DEMANDA (plaintiff arriba) y POR QUE importa cada lien heredable.
- NO recomendar oferta "agresiva" como negociacion MLS — esto es cash-only auction
con due diligence completa pre-bid + title search profesional obligatorio.
═══════════════════════════════════════════════════════════════
"""
# Bug 6: rama UNKNOWN+suspicious_low → bloque distressed hypothesis
if suspicious_unknown:
listing = price_validation.get("listing_price")
reasons = price_validation.get("possible_reasons") or []
investigation = price_validation.get("mandatory_investigation") or []
recommendation = price_validation.get("recommendation", "")
rejected = price_validation.get("rejected_sources") or []
reasons_block = ""
if reasons:
reasons_block = (
"\nHIPOTESIS ORDENADAS POR LIKELIHOOD en Florida (mencionar al menos 3 en tu analisis):\n"
+ "\n".join(f" - {r}" for r in reasons[:10])
)
investigation_block = ""
if investigation:
investigation_block = (
"\nDUE DILIGENCE OBLIGATORIA antes de oferta:\n"
+ "\n".join(f" - {i}" for i in investigation[:6])
)
rejected_block = ""
if rejected:
rejected_block = (
"\nFuentes descartadas por baja calidad:\n"
+ "\n".join(f" - {r}" for r in rejected[:3])
)
return f"""
═══════════════════════════════════════════════════════════════
DISTRESSED DEAL HYPOTHESIS — LISTING SOSPECHOSAMENTE BAJO
═══════════════════════════════════════════════════════════════
Listing: ${listing:,.0f} (estadisticamente raro para SFR en Florida si <$150K).
VEREDICTO PRELIMINAR: {recommendation}
{rejected_block}{reasons_block}{investigation_block}
INSTRUCCION OBLIGATORIA AL AGENTE:
- Trata este deal como PROBABLE DISTRESSED (foreclosure/tax_deed/REO) hasta que el
usuario confirme lo contrario. La PRIMER hipotesis no es "deal de mercado normal".
- Si tu metrica calculada parece "demasiado buena" (Cap Rate >12%, CoC >25%, DSCR >1.7),
asume que es ilusion derivada del listing-bajo-por-distress y advierte al inversionista.
- Recomienda explicitamente verificar deal_type real:
* Buscar lis pendens en el county clerk online (CCIS para Duval, MyCase Florida, etc.)
* Si es REO, contactar al banco directamente con offer cash 7-day-close
* Si es foreclosure auction, recalcular como MAB (cash-only, sin contingencias)
- NO recomiendes oferta "agresiva-baja" como si fuera negociacion MLS — en distressed
deals la formula es MAB del finance_calculator, no Strike*0.87.
═══════════════════════════════════════════════════════════════
"""
listing = price_validation.get("listing_price")
signed = price_validation.get("signed_max_discrepancy_pct", 0) or 0
direction = "BAJO" if signed < 0 else "SOBRE"
estimates = price_validation.get("market_estimates") or {}
sources = price_validation.get("sources_used") or []
recommendation = price_validation.get("recommendation", "")
est_lines = []
for label, key in [
("Zillow Zestimate", "zillow_zestimate"),
("Redfin Estimate", "redfin_estimate"),
("Tax assessed implied (FL ratio 0.85)", "tax_implied_market"),
("Comps mid (property_value)", "comps_mid"),
]:
v = estimates.get(key)
if v:
est_lines.append(f" * {label}: ${v:,.0f}")
if status == "CRITICAL_RED_FLAG":
reasons = price_validation.get("possible_reasons") or []
investigation = price_validation.get("mandatory_investigation") or []
reasons_block = ""
if reasons:
reasons_block = "\nPOSIBLES CAUSAS DEL PRECIO ANOMALO (mencionar al menos 3 en tu analisis):\n" + "\n".join(
f" - {r}" for r in reasons[:8]
)
investigation_block = ""
if investigation:
investigation_block = "\nDUE DILIGENCE OBLIGATORIA antes de oferta:\n" + "\n".join(
f" - {i}" for i in investigation[:5]
)
return f"""
═══════════════════════════════════════════════════════════════
CRITICAL RED FLAG — PRICE DISCREPANCY DETECTED
═══════════════════════════════════════════════════════════════
Listing: ${listing:,.0f} esta {abs(signed):.0f}% {direction} el market estimate.
Market estimates disponibles:
{chr(10).join(est_lines) if est_lines else " (sin estimates — flag derivada de fuente unica)"}
Fuentes usadas: {', '.join(sources) if sources else 'limitadas'}
VEREDICTO PRELIMINAR: {recommendation}
{reasons_block}{investigation_block}
INSTRUCCION OBLIGATORIA AL AGENTE:
- Menciona este RED FLAG explicitamente en tu output (al inicio de tu seccion principal).
- NO trates este deal como un deal normal — el precio anomalo invalida los benchmarks estandar.
- Si tu metrica calculada parece "demasiado buena" (Cap Rate >12%, CoC >25%, DSCR >1.7),
asume que es ilusion derivada del precio bajo y advierte al inversionista.
- Recomienda investigacion (court records, code enforcement, title search) ANTES de oferta.
═══════════════════════════════════════════════════════════════
"""
# WARNING
return f"""
PRICE WARNING: Listing ${listing:,.0f} esta {abs(signed):.0f}% {direction} el market estimate.
{recommendation}
Estimates: {', '.join(est_lines) if est_lines else 'limitados'}
Menciona esta discrepancia en tu analisis (no es critica pero el inversionista debe saberla).
"""
# ---------------------------------------------------------------------------
# Templates por agente
# ---------------------------------------------------------------------------
def build_photo_prompt(deal: "DealInputs") -> str:
"""Prompt legacy para PhotoInspector con fotos del listing.
DEPRECATED: llama3.2-vision en Ollama solo acepta 1 imagen por llamada.
Para multi-foto usar build_photo_prompt_single + build_photo_consolidation_prompt
via orchestrator.run_photo_inspector_multi().
"""
return f"""Analiza las fotos adjuntas del listado de la siguiente propiedad:
Direccion: {deal.address}
Tipo: SFR, {deal.beds} bed / {deal.baths} bath, {deal.sqft} sqft, ano {deal.year_built}
Precio listado: {_money(deal.price)}
Entrega tu reporte estructurado siguiendo tu formato (resumen ejecutivo + tabla por area + red flags + rehab total + recomendacion BRRRR/turnkey/pasar).
"""
def build_photo_prompt_single(deal: "DealInputs", photo_index: int, total_photos: int) -> str:
"""Prompt para analizar UNA foto del listing (multi-foto pipeline)."""
return f"""Estas analizando la foto {photo_index} de {total_photos} de la propiedad ubicada en:
{deal.address}
SFR, {deal.beds} bed / {deal.baths} bath, {deal.sqft} sqft, ano {deal.year_built}
Precio listado: {_money(deal.price)}
Para ESTA foto especifica, evalua:
1. Que habitacion o area es (cocina, bano, sala, exterior, techo, jardin, etc.)
2. Condicion visual (excelente / buena / regular / mala / muy mala)
3. Items especificos que necesitan trabajo
4. Red flags si los ves
5. Costo estimado de rehab SOLO para esta area (rango bajo-alto en USD)
Se conciso, solo lo que ves en esta foto. Habra una consolidacion despues con todas las fotos juntas.
"""
def build_photo_consolidation_prompt(deal: "DealInputs", individual_reports: list) -> str:
"""Prompt para consolidar analisis individuales de fotos en un reporte unico.
individual_reports: list of dicts con keys 'photo_index' y 'analysis'.
"""
reports_text = "\n\n".join(
f"=== FOTO {r['photo_index']} ===\n{r['analysis']}"
for r in individual_reports
)
return f"""Has analizado {len(individual_reports)} fotos individuales de la propiedad ubicada en {deal.address}.
Aqui estan tus reportes individuales por foto:
{reports_text}
AHORA consolidalos en UN reporte unico siguiendo tu formato estandar:
1. Resumen ejecutivo (2 lineas): condicion general y categoria de rehab (TURNKEY / LIGHT / MEDIUM / HEAVY / GUT)
2. Tabla por area con condicion y rehab estimado (combinar areas vistas en multiples fotos sin duplicar)
3. Red flags identificadas (TODAS las que aparecieron en cualquier foto)
4. Rehab total estimado (rango bajo-alto en USD, sumando areas sin contar duplicados)
5. Recomendacion: buen candidato para BRRRR, turnkey, o pasar?
IMPORTANTE:
- Si viste el mismo problema (ej. piso danado) en varias fotos, NO lo cuentes dos veces.
- Si dos fotos muestran la misma habitacion, consolida la evaluacion.
- Se preciso con los costos finales.
"""
def _format_verified_for_deal(verified_data: Optional[dict]) -> str:
"""Formatea verified_data como bloque de texto para inyectar al DealAnalyzer.
Solo incluye lo que el DealAnalyzer necesita: flood zone (afecta insurance),
FMR (afecta escenario Section 8).
"""
if not verified_data:
return ""
lines = []
flood = verified_data.get("flood") or {}
if flood:
zone = flood.get("zone", "?")
if flood.get("sfha"):
extra = "flood insurance OBLIGATORIA"
if zone == "VE":
extra += " (~$8K-$15K/ano segun la zona MAS peligrosa)"
elif zone.startswith("A"):
extra += " (~$2K-$4K/ano)"
lines.append(f"- FEMA Flood Zone: {zone} ({extra})")
else:
lines.append(f"- FEMA Flood Zone: {zone} (fuera de SFHA, no requiere flood insurance)")
fmr = verified_data.get("fmr") or {}
if fmr and fmr.get("fmr_3br"):
lines.append(
f"- HUD Fair Market Rent 3BR para el condado: ${fmr['fmr_3br']}/mes "
f"(year {fmr.get('year', '?')}) -- usa esto para el escenario Section 8"
)
if not lines:
return ""
return "\n\n=== DATOS OFICIALES VERIFICADOS (NO los inventes, NO los contradigas) ===\n" + "\n".join(lines)
def build_deal_prompt(
deal: "DealInputs",
photo_output: str,
verified_data: Optional[dict] = None,
calculated_block: Optional[str] = None,
price_validation: Optional[dict] = None,
) -> str:
"""Prompt para DealAnalyzer. Inyecta output de PhotoInspector + datos verificados + numeros calculados.
Fix #3: si calculated_block esta presente (Python finance_calculator.py output),
el LLM debe usar esos numeros EXACTOS sin recalcular. Esto elimina los bugs
aritmeticos detectados en Fix #2 smoke test (e.g. PASS vs NO BID confusion).
"""
rehab_section = ""
if deal.rehab_override is not None:
rehab_section = (
f"\n\nNOTA: el usuario provee rehab override de {_money(deal.rehab_override)}. "
"Usa ESE valor en lugar del estimado del PhotoInspector."
)
verified_block = _format_verified_for_deal(verified_data)
# Wave 1.5A: agregar bloque court records si owner_name disponible
court_block = _format_court_records_block(verified_data)
if court_block:
verified_block = verified_block + "\n" + court_block
# Nuevo: bloque con numeros pre-calculados en Python (DATO CERRADO para el LLM)
calc_block_text = ""
if calculated_block:
calc_block_text = "\n\n" + calculated_block
deal_type_indicator = ""
if getattr(deal, "deal_type", "mls").lower() in ("auction", "foreclosure", "tax_deed", "reo"):
deal_type_indicator = (
f"\n\nDEAL TYPE: {deal.deal_type.upper()} — "
"Aplica ESCENARIO 5 (AUCTION ACQUISITION) ademas de los 4 estandar.\n"
)
instructions_with_calc = """
═══ TU TAREA ═══
Los numeros estan PRE-CALCULADOS arriba. NO recalcules. NO contradigas los veredictos calculados.
Tu valor es:
1. PRESENTAR la tabla comparativa de manera clara (toma los numeros del bloque calculado)
2. EXPLICAR los detalles de cada escenario con los numeros dados
3. RECOMENDAR la mejor estrategia (la calculadora ya identifico cual)
4. EVALUAR SENSIBILIDAD: que pasa si la renta es 10% menor? si las tasas suben 1%?
(para esto SI razona/estima — la sensibilidad no esta en el bloque calculado)
Entrega tu analisis siguiendo tu formato de 4 puntos (tabla + detalles + recomendacion + sensibilidad).
""" if calculated_block else """
Entrega tu analisis siguiendo tu formato de 4 puntos (tabla comparativa + detalles por escenario + recomendacion + sensibilidad).
"""
price_warn_block = _format_price_validation_warning(price_validation)
return f"""Calcula los escenarios financieros para este deal de Florida.{price_warn_block}
DEAL INPUTS:
- Direccion: {deal.address}
- Precio listado: {_money(deal.price)}
- Renta mensual estimada: {_money(deal.rent)}
- Property tax anual: {_money(deal.property_tax)}
- Seguro anual: {_money(deal.insurance)}
- HOA mensual: {_money(deal.hoa)}
- Sqft: {deal.sqft}
- Beds / Baths: {deal.beds} / {deal.baths}
- Ano construccion: {deal.year_built}
- ARV: {_money(deal.arv)}{deal_type_indicator}
REPORTE DEL PHOTOINSPECTOR (usalo para la estimacion de rehab):
---
{photo_output}
---{rehab_section}{verified_block}{calc_block_text}
{instructions_with_calc}"""
def _format_verified_for_research(verified_data: Optional[dict]) -> str:
"""Formatea verified_data como bloque para FloridaResearcher.
Incluye TODO lo disponible: geocode, flood, FMR, huracanes.
"""
if not verified_data:
return "(sin datos verificados - razona desde tu conocimiento general pero indica incertidumbre)"
lines = []
geo = verified_data.get("geocode") or {}
if geo:
lines.append(f"- Address verificado por Census: {geo.get('matched_address', '?')}")
county = geo.get('county_name') or '?'
# Evitar "Miami-Dade County County" si el geocoder ya devolvio el sufijo
if not county.lower().rstrip().endswith(' county'):
county = f"{county} County"
lines.append(f"- Condado: {county}, {geo.get('state', '?')}")
if geo.get("zip"):
lines.append(f"- ZIP: {geo['zip']}")
flood = verified_data.get("flood") or {}
if flood:
zone = flood.get("zone", "?")
sfha = flood.get("sfha", False)
bfe = flood.get("bfe")
warning = ""
if zone == "VE":
warning = " - LA MAS PELIGROSA del sistema FEMA (velocity zone costera)"
elif zone in ("AE", "A"):
warning = " - inundable estandar"
elif zone in ("X",):
warning = " - bajo riesgo"
lines.append(
f"- FEMA Flood Zone: {zone}{warning}"
f" | SFHA={sfha}"
+ (f" | BFE={bfe}ft" if bfe else "")
+ " (fuente: FEMA NFHL oficial)"
)
else:
lines.append("- FEMA Flood Zone: NO DISPONIBLE (fetcher fallo, indica incertidumbre)")
fmr = verified_data.get("fmr") or {}
if fmr and any(fmr.get(k) for k in ("fmr_3br", "fmr_2br", "fmr_1br")):
parts = []
for label, key in [("Eff", "fmr_efficiency"), ("1BR", "fmr_1br"),
("2BR", "fmr_2br"), ("3BR", "fmr_3br"), ("4BR", "fmr_4br")]:
v = fmr.get(key)
if v:
parts.append(f"{label}=${v}")
lines.append(f"- HUD Fair Market Rent {fmr.get('year', '?')}: " + ", ".join(parts) + " (fuente: HUD User API oficial)")
else:
lines.append("- HUD FMR: NO DISPONIBLE (HUD_API_KEY ausente o fetcher fallo)")
hurricanes = verified_data.get("hurricanes") or []
if hurricanes:
lookback = (verified_data.get("hurricanes_summary") or {}).get("lookback_years", 20)
lines.append(f"- Huracanes ultimos {lookback} anos (cat 1+, paso a <=150mi del addr): {len(hurricanes)} eventos")
for h in hurricanes[:8]:
lines.append(
f" * {h.get('name', '?')} ({h.get('year', '?')}) Cat {h.get('category', '?')}"
f" - max {h.get('max_wind_mph', '?')} mph - mas cerca: {h.get('closest_pass_miles', '?')} mi"
)
if len(hurricanes) > 8:
lines.append(f" ... y {len(hurricanes) - 8} mas")
elif "hurricanes" in verified_data:
lines.append("- Huracanes ultimos 20 anos: 0 eventos cercanos cat 1+ (fuente: NOAA HURDAT2)")
else:
lines.append("- Huracanes: NO DISPONIBLE")
# Neighborhood classification
nbh = verified_data.get("neighborhood") or {}
if nbh and nbh.get("neighborhood_class") and nbh["neighborhood_class"] != "unclassified":
cls = nbh["neighborhood_class"]
score = nbh.get("class_score", "?")
conf = nbh.get("confidence_level", "?")
coverage = nbh.get("weight_coverage_pct", "?")
lines.append(
f"- Neighborhood Class: {cls} (score {score}/100, confianza {conf}, "
f"cobertura {coverage}% del algoritmo)"
)
ind = nbh.get("indicators") or {}
if ind:
ind_parts = []
if (v := ind.get("median_household_income")) is not None:
ind_parts.append(f"income ${v:,.0f}")
if (v := ind.get("owner_occupied_pct")) is not None:
ind_parts.append(f"owner-occupied {v:.0f}%")
if (v := ind.get("education_attainment_pct_bachelor_plus")) is not None:
ind_parts.append(f"bachelor+ {v:.0f}%")
if (v := ind.get("vacancy_rate")) is not None:
ind_parts.append(f"vacancy {v:.1f}%")
if (v := ind.get("crime_vs_national")) is not None:
ind_parts.append(f"crime {v:.2f}x national")
if (v := ind.get("median_home_value")) is not None:
ind_parts.append(f"median home ${v:,.0f}")
if ind_parts:
lines.append(" Indicadores objetivos: " + " | ".join(ind_parts))
impl = nbh.get("investment_implications") or {}
if impl:
lines.append(
f" Implicancias inversion: "
f"Buy&Hold={impl.get('buy_hold_viability', '?')}, "
f"Section 8={impl.get('section_8_viability', '?')}, "
f"Apreciacion={impl.get('appreciation_potential', '?')}"
)
elif nbh:
# Unclassified - sin keys o sin datos
ne_errors = (nbh.get("errors") or [])[:2]
lines.append(
f"- Neighborhood Class: UNCLASSIFIED "
f"(confianza={nbh.get('confidence_level', '?')}). "
+ (f"Causa: {ne_errors[0]}" if ne_errors else "")
)
errors = verified_data.get("fetch_errors") or []
if errors:
lines.append(f"- AVISO: algunos fetchers fallaron ({len(errors)}): " + "; ".join(errors[:3]))
return "\n".join(lines)
def build_research_prompt(
deal: "DealInputs",
verified_data: Optional[dict] = None,
price_validation: Optional[dict] = None,
) -> str:
"""Prompt para FloridaResearcher. Inyecta datos verificados (Census, FEMA, HUD, NOAA)."""
verified_block = _format_verified_for_research(verified_data)
# Wave 1.5A: court records owner_name + lis pendens
court_block = _format_court_records_block(verified_data)
if court_block:
verified_block = verified_block + "\n" + court_block
price_warn_block = _format_price_validation_warning(price_validation)
return f"""Investiga el mercado inmobiliario para esta propiedad en Florida.{price_warn_block}
Direccion: {deal.address}
Tipo: SFR, {deal.beds}/{deal.baths}, ano {deal.year_built}, {deal.sqft} sqft
Precio listado: {_money(deal.price)}
=== DATOS VERIFICADOS DE FUENTES OFICIALES (usalos EXACTOS, NO los inventes ni contradigas) ===
{verified_block}
=== TU TAREA ===
Entrega tu reporte de 5 secciones (condado/ciudad, barrio, riesgos FL, mercado rentas, tendencias macro).
INSTRUCCION ESPECIAL (Bug 6) — DISTRESSED MARKET CONTEXT:
Si el bloque "DISTRESSED DEAL HYPOTHESIS" o "RED FLAG" arriba esta activo, en la **Seccion 3 (Riesgos FL)**
agrega obligatoriamente una subseccion titulada **"3.X Mercado de foreclosures / tax_deeds / REO en {{condado}}"**
donde:
- Citas la tasa de foreclosure tipica del condado (ej. Duval ~0.4% mensual de inventario MLS, 3x el avg USA).
- Mencionas el ciclo tipico foreclosure FL (lis pendens → judicial sale → 10-day right of redemption).
- Identificas las plataformas donde se publican subastas: CCIS del clerk del condado, MyCase Florida,
Realtybid, Auction.com, Hubzu (Altisource).
- Mencionas si el condado tiene "redemption period" para tax_deeds (FL standard: 1 ano para tax deed,
10 dias para foreclosure judicial).
- Recomendas el primer paso de DD: buscar la direccion en el sitio del clerk del condado.
REGLAS DE USO DE DATOS:
- Para flood zone usa el dato FEMA de arriba (NO digas otra zona).
- Para huracanes usa la lista NOAA de arriba (NO inventes huracanes que no esten en la lista).
- Para market rent usa el HUD FMR de arriba como benchmark.
- Para Neighborhood Class, USA el dato del fetcher (A/B/C/D) y SOLO los indicadores objetivos listados.
- Para todo lo demas (walkability, tendencias macro), razona desde tu conocimiento pero:
- Si no tienes datos verificados de la zona especifica, dilo explicitamente.
- NO inventes numeros especificos de la zona.
- Si ves fetch_errors arriba, mencionalo en la conclusion como limitacion del reporte.
═══ REGLA CRITICA DE COMPLIANCE (Fair Housing Act federal) ═══
La clasificacion del vecindario es ECONOMICA Y OBJETIVA, NO demografica.
PROHIBIDO TOTALMENTE:
- "gentrified" / "gentrifying" / "transitioning" / "up-and-coming"
- "urban" / "inner-city" / "outer suburbs" como eufemismos
- Cualquier referencia a composicion racial, etnica o nacionalidad de residentes
- "good neighborhood" / "bad neighborhood" / "rough area" sin indicadores numericos
- "minority area" / "ethnic neighborhood" / "diverse demographics"
OBLIGATORIO:
- Si describis la clase del vecindario, cita SOLO los indicadores objetivos:
income, owner-occupancy, education attainment, vacancy, crime ratio, days on market.
- Ejemplo CORRECTO: "Clase D segun indicadores: median income $28K, owner-occupied 22%,
vacancy 18%, crime 2.4x national average."
- Ejemplo PROHIBIDO: "Barrio de mayoria afroamericana / hispana / etc."
Esto cumple con Fair Housing Act y es eticamente correcto. La data subyacente es
publica (Census + FBI) y no contiene componentes demograficos en este sistema.
"""
def build_lender_prompt(
deal: "DealInputs",
profile: "BuyerProfile",
deal_analyzer_output: str,
price_validation: Optional[dict] = None,
verified_data: Optional[dict] = None,
) -> str:
"""Prompt para LenderMatcher. Inyecta perfil del comprador + estrategia ganadora."""
price_warn_block = _format_price_validation_warning(price_validation)
# Wave 1.5A v1.2: court records owner pattern afecta recomendaciones de lender
# (LLC vs persona vs trust → diferentes productos hipotecarios)
court_block = _format_court_records_block(verified_data)
court_block_text = "\n" + court_block if court_block else ""
return f"""Recomienda lenders para este deal de Florida con base en la estrategia ganadora identificada por DealAnalyzer.{price_warn_block}{court_block_text}
DEAL:
- Direccion: {deal.address}
- Precio: {_money(deal.price)}
- ARV: {_money(deal.arv)}
- Renta: {_money(deal.rent)}/mes
PERFIL DEL COMPRADOR:
- Clase: {profile.profile_class} - {PROFILE_DESCRIPTIONS.get(profile.profile_class, "no especificado")}
- FICO: {_none_marker(profile.fico)}
- LLC seasoning (anos): {_none_marker(profile.llc_seasoning_years)}
- Capital disponible: {_money(profile.capital_available)}
- Experiencia previa (deals cerrados): {_none_marker(profile.experience_deals)}
- Nacionalidad: {_none_marker(profile.nationality)}
- Estatus visa: {_none_marker(profile.visa_status)}
REPORTE DE DEALANALYZER (usalo para identificar la estrategia ganadora):
---
{deal_analyzer_output}
---
Entrega tu reporte de 9 puntos segun tu formato. Si un dato del perfil del comprador NO fue provisto arriba, NO inventes un valor - asume lo mas conservador o pide aclaracion.
"""
def build_synthesis_prompt(
deal: "DealInputs",
photo_output: str,
deal_analyzer_output: str,
research_output: str,
lender_output: str,
verified_data: Optional[dict] = None,
price_validation: Optional[dict] = None,
) -> str:
"""Prompt final para Coordinator. Sintetiza los 4 outputs especialistas + datos verificados."""
price_warn_block = _format_price_validation_warning(price_validation)
# Wave 1.5A: court records info al Coordinator
court_block = _format_court_records_block(verified_data)
# Resumen compacto de datos verificados para que Coordinator los considere
# al sintetizar (especialmente flood zone y FMR).
verified_summary = ""
if verified_data:
flood = verified_data.get("flood") or {}
fmr = verified_data.get("fmr") or {}
hur = verified_data.get("hurricanes") or []
bits = []
if flood:
sfha_str = "SFHA=SI" if flood.get("sfha") else "SFHA=NO"
bits.append(f"FEMA={flood.get('zone', '?')} ({sfha_str})")
if fmr and fmr.get("fmr_3br"):
bits.append(f"HUD FMR 3BR=${fmr['fmr_3br']}/mo")
if hur:
bits.append(f"{len(hur)} huracanes Cat 1+ <=150mi/20anos")
if bits:
verified_summary = (
"\n=== DATOS OFICIALES VERIFICADOS (snapshot) ===\n"
+ " | ".join(bits)
+ "\n(Estos datos son oficiales. NO los contradigas en tu veredicto.)\n"
)
return f"""Sintetiza los siguientes 4 inputs de tus agentes especialistas y entrega el veredicto final.{price_warn_block}
DEAL:
- Direccion: {deal.address}
- Precio: {_money(deal.price)}
- Renta: {_money(deal.rent)}/mes
- Tax: {_money(deal.property_tax)}/ano - Seguro: {_money(deal.insurance)}/ano - HOA: {_money(deal.hoa)}/mes
- {deal.beds} bed / {deal.baths} bath / {deal.sqft} sqft / ano {deal.year_built}
- ARV: {_money(deal.arv)}
{verified_summary}{court_block}
=======================================
INPUT 1 - PhotoInspector:
=======================================
{photo_output}
=======================================
INPUT 2 - DealAnalyzer:
=======================================
{deal_analyzer_output}
=======================================
INPUT 3 - FloridaResearcher:
=======================================
{research_output}
=======================================
INPUT 4 - LenderMatcher:
=======================================
{lender_output}
=======================================
Da tu VEREDICTO FINAL siguiendo tu formato de 7 puntos.
"""
# ---------------------------------------------------------------------------
# ValueEstimator (Wave 2) - estima valor real vs listing price
# ---------------------------------------------------------------------------
def build_value_prompt(
deal: "DealInputs",
property_value_data: dict,
verified_data: Optional[dict] = None,
price_validation: Optional[dict] = None,
) -> str:
"""Prompt para ValueEstimator. Inyecta property_value_data (pre-calculado) + contexto."""
price_warn_block = _format_price_validation_warning(price_validation)
# Wave 1.5A v1.2: court records — tax_assessed_value oficial es input clave para valuation
court_block_for_value = _format_court_records_block(verified_data)
pv = property_value_data or {}
ev = pv.get("estimated_value") or {}
ded = pv.get("deductions") or {}
comps = pv.get("comps_used") or []
# Helpers para evitar f-string crashes con None
ppsqft_comps_val = pv.get("price_per_sqft_comps_avg")
ppsqft_comps_str = f"${ppsqft_comps_val:,.0f}" if ppsqft_comps_val else "N/A"
ppsqft_subject_val = pv.get("price_per_sqft_subject", 0)
overpriced_val = pv.get("overpriced_pct")
overpriced_str = f"{overpriced_val}%" if overpriced_val is not None else "N/A"
# Formatear comps en tabla markdown si hay
comps_block = ""
if comps:
comps_block = "\n### Comparables recientes (Firecrawl/Zillow):\n\n"
comps_block += "| # | Address | Sold | Sqft | $/sqft | Beds |\n|---|---|---|---|---|---|\n"
for i, c in enumerate(comps, 1):
comps_block += (
f"| {i} | {c.get('address', '?')} | ${c.get('sold_price', 0):,} | "
f"{c.get('sqft', '?')} | ${c.get('price_per_sqft', 0):.0f} | "
f"{c.get('beds_text', '?')} |\n"
)
else:
comps_block = (
"\n### Comparables:\n"
"No hay comps Firecrawl disponibles (ENABLE_FIRECRAWL_COMPS=false o fetcher fallo). "
"Estimación basada solo en tax_assessed (si hay) + deductions automáticas.\n"
)
# Verified data summary
verified_summary = ""
if verified_data:
flood = (verified_data.get("flood") or {}).get("zone", "?")
sfha = (verified_data.get("flood") or {}).get("sfha", False)
nbh = (verified_data.get("neighborhood") or {}).get("neighborhood_class", "?")
verified_summary = f"- FEMA zone: {flood} (SFHA: {sfha}) | Neighborhood class: {nbh}\n"
# Bug 4-Minor: Si property_value tiene confidence=low O fue rechazado por price_validator,
# ValueEstimator NO tiene comps reales para interpretar. Sin instrucciones extra cae a
# 300 palabras dando un "no hay datos" output. Inyectamos una tarea-de-razonamiento explicita.
confidence_low_or_rejected = (
ev.get('confidence') == 'low'
or (price_validation or {}).get('suspicious_low_listing')
)
fallback_task = ""
if confidence_low_or_rejected:
# Estimaciones razonables para Florida SFR retail (sin comps)
sqft = deal.sqft or 1200
# Rule of thumb FL SFR retail $/sqft por clase de barrio:
# Class A: $250-$400/sqft, B: $180-$280, C: $120-$200, D: $80-$150
nbh_class = ((verified_data or {}).get("neighborhood") or {}).get("neighborhood_class", "?")
sqft_estimates_by_class = {
"A": (250, 400, "barrio premium, walkability alta, top schools"),
"B": (180, 280, "barrio working-professional, schools OK"),
"C": (120, 200, "barrio working-class, schools mixtos"),
"D": (80, 150, "barrio bajo income, schools en dificultad"),
}
rng = sqft_estimates_by_class.get(nbh_class, (100, 200, "rango promedio FL"))
low_psf, high_psf, class_desc = rng
low_val = int(sqft * low_psf)
high_val = int(sqft * high_psf)
mid_val = (low_val + high_val) // 2
# Backout del rent: 1% rule típica FL SFR (rent/price ~0.7-1.1% mensual)
rent_implied_low = int((deal.rent or 0) * 100)
rent_implied_high = int((deal.rent or 0) * 145)
rent_implied_mid = (rent_implied_low + rent_implied_high) // 2
fallback_task = f"""
═══ INSTRUCCION CRITICA (property_value rechazado) ═══
El bloque "Valor estimado" arriba vino con confidence='low' o fue descartado por price_validator
(no hay comps reales ni tax_assessed). NO repitas que "no hay datos" como si fuera el output.
EN VEZ: razona el valor probable usando 3 metodologias independientes y triangula:
**Metodologia 1 — $/sqft por clase de barrio (Class {nbh_class}, {class_desc}):**
- Rango tipico FL: ${low_psf}-${high_psf}/sqft
- Aplicado a {sqft:,} sqft: ${low_val:,} - ${high_val:,} (mid: ${mid_val:,})
**Metodologia 2 — backout desde rent (FL 1% rule):**
- Rent listado: {_money(deal.rent)}/mo
- A 1% rule: valor implicado ${rent_implied_mid:,} (rango ${rent_implied_low:,} - ${rent_implied_high:,})
**Metodologia 3 — backout desde ARV menos rehab:**
- ARV: {_money(deal.arv)}
- Rehab pendiente: estimar segun edad del inmueble (year_built {deal.year_built})
- Valor as-is = ARV - rehab - margen_developer (~15-20%)
TU TAREA EXPANSIVA:
1. Calcula cada una de las 3 metodologias paso a paso (mostrar derivacion)
2. Triangula: cual es el valor as-is mas probable considerando las 3 estimaciones?
3. Compara contra el listing price: el listing es excepcional bajo, normal, o sospechoso?
4. Si el listing es <50% del valor triangulado, el deal CASI CERTAMENTE es distressed
(foreclosure / tax_deed / REO / pre-foreclosure short sale). Mencionalo explicitamente.
5. Recomienda al inversionista el siguiente paso: court records search en el county clerk
para confirmar deal_type real ANTES de hacer cualquier oferta.
6. Tu output debe ser >=500 palabras (Bug 1 exhaustividad), con razonamiento en cada paso.
NO digas "no hay comps disponibles" como output principal. Razona con las 3 metodologias
basadas en datos reales (sqft, rent, ARV, neighborhood class) que SI tenes en el prompt.
"""
court_block_text = "\n" + court_block_for_value if court_block_for_value else ""
return f"""Analiza el pricing de este deal:{price_warn_block}{fallback_task}{court_block_text}
DEAL:
- Address: {deal.address}
- Listing price: {_money(pv.get('listing_price', deal.price))}
- {deal.beds} bed / {deal.baths} bath / {deal.sqft} sqft / ano {deal.year_built}
- ARV: {_money(deal.arv)}
═══ DATOS PRE-CALCULADOS (Python — NO recalcules) ═══
**Valor estimado:**
- Low: {_money(ev.get('low'))}
- Mid: {_money(ev.get('mid'))}
- High: {_money(ev.get('high'))}
- Confidence: **{ev.get('confidence', '?')}**
**Tax assessed:** {_money(pv.get('tax_assessed_value')) if pv.get('tax_assessed_value') else 'NO DISPONIBLE (scraper Miami-Dade pendiente)'}
**Pricing metrics:**
- $/sqft del listing: ${ppsqft_subject_val:,.0f}
- $/sqft promedio comps: {ppsqft_comps_str}
- **Overpriced %:** {overpriced_str}
- **Inflation score:** {pv.get('inflation_score', 0)}/10
{comps_block}
**Deductions automaticas (por edad del inmueble, FL norms):**
- AC central: -{_money(ded.get('ac', 0))} (year_built<2010)
- Roof shingle: -{_money(ded.get('roof', 0))} (year_built<2005)
- Plumbing polybutylene: -{_money(ded.get('plumbing', 0))} (1978-1995)
- Electrical panel: -{_money(ded.get('panel', 0))} (year_built<1990)
- **Total deductions:** -{_money(ded.get('total', 0))}
**Fuentes usadas:** {', '.join(pv.get('sources_used', [])) or 'ninguna'}
**Errores de fetch:** {len(pv.get('fetch_errors', []))}{'; '.join(pv.get('fetch_errors', [])[:2]) if pv.get('fetch_errors') else 'ninguno'}
═══ DATOS VERIFICADOS ═══
{verified_summary}
═══ TU TAREA ═══
Entrega tu reporte siguiendo EXACTAMENTE el formato de tu system prompt:
1. Resumen del valor
2. Valor estimado
3. Comparables analizados (tabla si hay)
4. Deducciones aplicadas
5. Veredicto de pricing (inflation score + status + implicancia para oferta)
6. Limitaciones del análisis
NO RECALCULES. Los numeros estan arriba. Tu valor es INTERPRETAR.
"""
# ---------------------------------------------------------------------------
# OfferStrategist (Wave 2) - genera Strike/Stretch/Walk-Away + estrategia
# ---------------------------------------------------------------------------
def build_offer_prompt(
deal: "DealInputs",
profile: "BuyerProfile",
value_estimate_output: str,
deal_analysis_output: str,
verified_data: Optional[dict] = None,
computed_scenarios: Optional[dict] = None,
price_validation: Optional[dict] = None,
) -> str:
"""Prompt para OfferStrategist. Inyecta ValueEstimator + DealAnalyzer outputs + context."""
price_warn_block = _format_price_validation_warning(price_validation)
# Wave 1.5A v1.2: court records — plaintiff type afecta oferta (negociar con bank vs auction)
court_block_for_offer = _format_court_records_block(verified_data)
deal_type = getattr(deal, "deal_type", "mls").lower()
is_auction = deal_type in ("auction", "foreclosure", "tax_deed", "reo")
# MAB info si es auction (sacado de computed_scenarios)
mab_block = ""
if is_auction and computed_scenarios:
au = (computed_scenarios.get("scenarios") or {}).get("auction") or {}
if au and "mab" in au:
mab_block = f"""
═══ DATOS DE AUCTION (de finance_calculator) ═══
- **MAB (Maximum Allowable Bid):** {_money(au.get('mab'))}
- Pass threshold (MAB × 0.70): {_money(au.get('pass_threshold'))}
- Caution threshold (MAB × 0.95): {_money(au.get('caution_threshold'))}
- Veredict calculado: **{au.get('verdict')}**
- Cash needed Day-1: {_money(au.get('cash_needed_day_one'))}
IMPORTANTE: usa estos MAB/thresholds del finance_calculator. NO inventes tu propia formula.
"""
# Verified summary breve
verified_summary = ""
if verified_data:
flood = (verified_data.get("flood") or {}).get("zone", "?")
sfha = (verified_data.get("flood") or {}).get("sfha", False)
nbh = (verified_data.get("neighborhood") or {}).get("neighborhood_class", "?")
fmr_3br = (verified_data.get("fmr") or {}).get("fmr_3br")
verified_summary = (
f"- FEMA zone: {flood} (SFHA: {sfha})\n"
f"- Neighborhood class: {nbh}\n"
)
if fmr_3br:
verified_summary += f"- HUD FMR 3BR: {_money(fmr_3br)}/mo\n"
deal_type_label = {
"mls": "MLS (listado normal)",
"off_market": "Off-market (private deal)",
"auction": "Auction",
"foreclosure": "Foreclosure auction",
"tax_deed": "Tax deed auction",
"reo": "REO (bank-owned)",
}.get(deal_type, deal_type)
court_block_text = "\n" + court_block_for_offer if court_block_for_offer else ""
return f"""Generá la oferta estratégica para este deal.{price_warn_block}{court_block_text}
DEAL:
- Address: {deal.address}
- Listing price: {_money(deal.price)}
- Deal type: **{deal_type_label}** {'(MODO 2: AUCTION)' if is_auction else '(MODO 1: MLS)'}
- {deal.beds} bed / {deal.baths} bath / {deal.sqft} sqft / ano {deal.year_built}
- ARV estimado: {_money(deal.arv)}
{f'- Case number: {deal.case_number}' if getattr(deal, 'case_number', None) else ''}
{f'- Occupancy: {deal.occupancy_status}' if getattr(deal, 'occupancy_status', None) else ''}
PROFILE DEL COMPRADOR:
- Clase: {profile.profile_class}{PROFILE_DESCRIPTIONS.get(profile.profile_class, 'no especificado')}
- FICO: {_none_marker(profile.fico)}
- Capital disponible: {_money(profile.capital_available)}
- Nacionalidad: {_none_marker(profile.nationality)}
═══ DATOS VERIFICADOS ═══
{verified_summary}
{mab_block}
═══ OUTPUT DE ValueEstimator (valor real estimado) ═══
{value_estimate_output}
═══ OUTPUT DE DealAnalyzer (escenarios financieros) ═══
{deal_analysis_output}
═══ TU TAREA ═══
{('MODO 2 — AUCTION BID: usa el MAB del finance_calculator (arriba). NO calcules tu propia formula. Conservative bid = MAB * 0.85, Max bid = MAB, Walk-away = MAB * 1.05.' if is_auction else 'MODO 1 — MLS OFFER: usa las formulas Strike (mid*0.87) / Stretch (mid*0.95) / Walk-Away (mid*1.05) con ajustes segun señales.')}
Entrega la recomendacion de oferta siguiendo EXACTAMENTE el formato de tu system prompt:
1. Strike Offer / Stretch / Walk-Away (cifras concretas)
2. Razon del Strike
3. Estrategia de presentacion (angulo de ataque + NO menciones)
4. Contra-ofertas anticipadas
5. Deal killers
6. Terminos sugeridos del contrato
7. Proximos pasos pre-oferta
RECORDATORIO: cifras en USD sin centavos, 100% español natural, terminos USA en ingles cuando son nombres propios (DOM, SFHA, MAB, REO, etc.). NO inventes datos que no esten en los inputs arriba.
"""
# ---------------------------------------------------------------------------
# ContextualGlossaryAgent - briefing ejecutivo en espanol con contexto USA
# ---------------------------------------------------------------------------
def build_glossary_prompt(
deal: "DealInputs",
profile: "BuyerProfile",
tranchi_output: str,
deal_analysis_output: str,
research_output: str,
lender_output: str,
verified_data: Optional[dict] = None,
value_estimate_output: Optional[str] = None,
offer_strategy_output: Optional[str] = None,
price_validation: Optional[dict] = None,
) -> str:
"""Prompt para ContextualGlossaryAgent.
Recibe los 4 outputs especialistas + el veredicto del Coordinator y produce
un briefing ejecutivo en espanol con contexto USA para inversionistas
extranjeros con background tecnico (PMs, engineers, finance pros).
"""
# Bug 2: Red flag block para ContextualGlossaryAgent. Si CRITICAL,
# el briefing debe destacarlo AL INICIO con un H1 "ALERTA CRITICA".
price_warn_block = _format_price_validation_warning(price_validation)
# Wave 1.5A: court records (owner_name + RE# + lis pendens)
court_block = _format_court_records_block(verified_data)
glossary_redflag_instruction = ""
pv_status = price_validation.get("status") if price_validation else None
pv_suspicious_unknown = (
price_validation
and pv_status == "UNKNOWN"
and price_validation.get("suspicious_low_listing", False)
)
if pv_status == "CRITICAL_RED_FLAG":
glossary_redflag_instruction = """
═══ INSTRUCCION CRITICA PARA EL BRIEFING ═══
El precio del listing tiene una DISCREPANCIA CRITICA con el market value (ver bloque RED FLAG arriba).
TU BRIEFING DEBE EMPEZAR con una seccion H1 destacada:
# ALERTA CRITICA — PRECIO ANOMALO DETECTADO
Esta seccion va ANTES de cualquier otra cosa (antes del resumen ejecutivo, antes del veredicto).
Explica al inversionista en 4-6 lineas:
1. Cuanto bajo/sobre el market esta el listing (%)
2. Que las metricas aparentemente "espectaculares" (Cap Rate, CoC, DSCR) pueden ser ilusorias
3. Que NO debe proceder con oferta hasta investigar las causas (court records, code enforcement, title)
4. Mencionar 3-5 posibles causas concretas del precio anomalo
DESPUES de esa seccion de alerta, sigue con tu formato normal (resumen ejecutivo, glosario, etc.),
PERO en cada metrica financiera relevante agrega una nota "(ver alerta de precio arriba)".
"""
elif pv_suspicious_unknown:
# Bug 6: UNKNOWN-suspicious-low → priorizar hipotesis distressed
glossary_redflag_instruction = """
═══ INSTRUCCION CRITICA PARA EL BRIEFING (DISTRESSED HYPOTHESIS) ═══
El listing es estadisticamente raro (sub-$150K SFR en Florida) y NO se pudo validar
contra fuentes de mercado confiables. La hipotesis primaria es DEAL DISTRESSED.
TU BRIEFING DEBE EMPEZAR con seccion H1 destacada:
# HIPOTESIS PROBABLE: DEAL DISTRESSED (FORECLOSURE / TAX DEED / REO)
Esta seccion va ANTES de cualquier otra cosa. Explica al inversionista:
1. Que el listing ($X) es raro para SFR en Florida (<$150K es <5% del mercado MLS retail).
2. Que las 3 causas mas probables son (en orden): foreclosure auction / REO / tax deed.
3. Que el deal_type que ingreso ("MLS") puede estar EQUIVOCADO — pedirle que verifique.
4. Que las metricas aparentemente "espectaculares" son tipicas de listings distressed
donde el precio refleja problemas heredables, NO valor de mercado normal.
5. Que el primer paso es lookup en CCIS del condado para detectar lis pendens.
DESPUES de esa seccion, continua con tu formato normal pero subraya que el analisis es
PRELIMINAR hasta confirmar el deal_type real. NO presentes la recomendacion (Section 8 / etc.)
como decision final sin asterisco "(*condicional a confirmar el deal_type)".
"""
elif pv_status == "WARNING":
glossary_redflag_instruction = """
═══ NOTA PARA EL BRIEFING ═══
Hay un PRICE WARNING (no critico pero relevante). Menciona la discrepancia en el resumen ejecutivo
con un emoji pero no como bloque destacado independiente.
"""
# Snapshot compacto de datos verificados
verified_snapshot = ""
if verified_data:
flood = verified_data.get("flood") or {}
fmr = verified_data.get("fmr") or {}
hurricanes_summary = verified_data.get("hurricanes_summary") or {}
neighborhood = verified_data.get("neighborhood") or {}
geo = verified_data.get("geocode") or {}
snap_lines = []
if geo.get("matched_address"):
snap_lines.append(f"- Address verificado: {geo['matched_address']}")
snap_lines.append(f"- Condado: {geo.get('county_name', '?')}, {geo.get('state', '?')}")
if flood:
snap_lines.append(f"- FEMA Flood Zone: {flood.get('zone', '?')} (SFHA: {flood.get('sfha', '?')}, BFE: {flood.get('bfe', 'N/A')} ft)")
if fmr:
snap_lines.append(f"- HUD FMR 3BR: ${fmr.get('fmr_3br', 'N/A')}/mes (year {fmr.get('year', '?')})")
if fmr.get("fmr_2br"):
snap_lines.append(f"- HUD FMR 2BR: ${fmr['fmr_2br']}/mes")
if hurricanes_summary:
snap_lines.append(f"- Huracanes Cat 1+ ultimos 20 anos: {hurricanes_summary.get('total_hurricanes_nearby', 'N/A')} eventos cercanos")
if neighborhood and neighborhood.get("neighborhood_class") not in (None, "unclassified"):
ind = neighborhood.get("indicators") or {}
snap_lines.append(
f"- Neighborhood class: {neighborhood['neighborhood_class']} "
f"(score {neighborhood.get('class_score', '?')}/100, "
f"confianza {neighborhood.get('confidence_level', '?')})"
)
if ind.get("median_household_income"):
snap_lines.append(f" income tract: ${ind['median_household_income']:,.0f}")
if ind.get("owner_occupied_pct") is not None:
snap_lines.append(f" owner-occupied: {ind['owner_occupied_pct']}%")
if ind.get("education_attainment_pct_bachelor_plus") is not None:
snap_lines.append(f" bachelor+: {ind['education_attainment_pct_bachelor_plus']}%")
if snap_lines:
verified_snapshot = (
"\n=== DATOS VERIFICADOS DE FUENTES OFICIALES ===\n"
+ "\n".join(snap_lines)
+ "\n\nUsa estos datos para contextualizar. NO los inventes ni contradigas.\n"
)
# Wave 1.5A: agregar court records al briefing si disponibles
if court_block:
verified_snapshot = verified_snapshot + "\n" + court_block
# Indicador opcional de deal type especial (auction/foreclosure)
deal_type_indicator = ""
deal_type = getattr(deal, "deal_type", None)
if deal_type and deal_type.lower() in ("auction", "foreclosure", "tax_deed", "reo"):
case_num = getattr(deal, "case_number", None) or "(no provisto)"
occupancy = getattr(deal, "occupancy_status", None) or "unknown"
deal_type_indicator = (
f"\nDEAL TYPE: {deal_type.upper()}\n"
f"- Case number: {case_num}\n"
f"- Occupancy status: {occupancy}\n"
"IMPORTANTE: este deal es una AUCTION/foreclosure. Aplica reglas especiales:\n"
"- Cash-only (sin financing al cierre)\n"
"- Sin inspeccion previa (rehab worst-case)\n"
"- Deudas heredables segun subtipo (foreclosure vs tax_deed vs REO)\n"
"- Title search obligatorio antes del bid\n"
f"- Si occupied: reservar $5K-$15K para eviction + 30-90 dias\n\n"
"AGREGA en tu briefing la seccion especial '## Consideraciones especiales — Auction Deal' "
"como tu system prompt indica.\n"
)
return f"""Recibiste los analisis tecnicos de 4 agentes especialistas + el veredicto sintetizado del coordinador. Tu tarea: producir un Briefing Ejecutivo en espanol para un profesional con background tecnico no-USA.{price_warn_block}{glossary_redflag_instruction}
═══ DEAL ═══
Address: {deal.address}
Precio: {_money(deal.price)}
Renta listada: {_money(deal.rent)}/mes
Property tax anual: {_money(deal.property_tax)}
Seguro anual: {_money(deal.insurance)}
HOA mensual: {_money(deal.hoa)}
Sqft: {deal.sqft}
Beds/Baths: {deal.beds}/{deal.baths}
Year built: {deal.year_built}
ARV estimado: {_money(deal.arv)}
Rehab override: {_money(deal.rehab_override) if deal.rehab_override else '(auto, lo estima PhotoInspector)'}
{deal_type_indicator}
═══ PERFIL DEL COMPRADOR ═══
Class: {profile.profile_class} - {PROFILE_DESCRIPTIONS.get(profile.profile_class, 'no especificado')}
FICO: {_none_marker(profile.fico)}
LLC seasoning: {_none_marker(profile.llc_seasoning_years)} anos
Capital disponible: {_money(profile.capital_available)}
Experiencia: {_none_marker(profile.experience_deals)} deals previos
Nacionalidad: {_none_marker(profile.nationality)}
{verified_snapshot}
═══ ANALISIS DE LOS AGENTES ═══
### Veredicto coordinador (Coordinator):
{tranchi_output}
### Analisis financiero (DealAnalyzer):
{deal_analysis_output}
### Mercado y barrio (FloridaResearcher):
{research_output}
### Financiamiento (LenderMatcher):
{lender_output}
{f'''### Estimacion de Valor Real (ValueEstimator):
{value_estimate_output}
''' if value_estimate_output else ''}
{f'''### Oferta Recomendada (OfferStrategist):
{offer_strategy_output}
''' if offer_strategy_output else ''}
═══ TU TAREA ═══
Genera el Briefing Ejecutivo siguiendo EXACTAMENTE la estructura definida en tu system prompt:
1. Resumen del deal
2. Veredicto ajustado por riesgo
3. Tesis de inversion
4. Metricas financieras clave
5. Estructura de capital
6. Valor real vs listing (USA el output de ValueEstimator arriba — overpriced %, inflation score, comps si hay)
7. Oferta recomendada (USA el output de OfferStrategist arriba — Strike/Stretch/Walk-Away + estrategia)
8. Registro de riesgos (rojo/amarillo/verde)
9. Consideraciones especificas USA
10. Recomendacion de financiamiento
11. Plan de accion priorizado
REGLAS CRITICAS:
- Output 100% en espanol natural latinoamericano
- Terminos USA en ingles SOLO cuando son nombres propios (DSCR, FEMA, HUD, BRRRR, etc.)
- NO mezcles spanglish en frases ("Risk Profile: HIGH" - PROHIBIDO)
- Explica el contexto USA de cada termino importante (filosofia QUE/POR-QUE/IMPLICACION)
- Se conciso pero educativo - estilo senior consultant briefing
- Usa los datos verificados cuando aplique
- NO repitas el analisis completo, sintetiza + traduce + educa
"""
def build_email_prompt(
email_type: int,
deal: "DealInputs",
profile: "BuyerProfile",
last_analysis: Optional["AnalysisResult"],
recipient: dict,
language: str = "english",
urgency: str = "medium",
) -> str:
"""Prompt para EmailComposer (invocado on-demand post-analisis)."""
if email_type not in EMAIL_TYPES:
raise ValueError(f"email_type debe ser 1-10, recibido {email_type}")
lender_context = ""
if last_analysis is not None:
# last_analysis.lender es un dict (asdict del AgentResult)
lender_dict = last_analysis.lender if isinstance(last_analysis.lender, dict) else {}
lender_out = lender_dict.get("output", "")
if lender_out:
lender_context = f"""
CONTEXTO DE LENDERMATCHER (lender ganador identificado):
---
{lender_out}
---"""
return f"""Genera el siguiente email:
Tipo: {email_type} ({EMAIL_TYPES[email_type]})
Recipient: {recipient.get('name', '(no provisto)')}, {recipient.get('role', '(no provisto)')} en {recipient.get('company', '(no provisto)')}
Idioma: {language}
Urgencia: {urgency}
Deal info:
- Direccion: {deal.address}
- Precio: {_money(deal.price)}
- ARV: {_money(deal.arv)}
- Renta mensual: {_money(deal.rent)}
- Rehab estimado: {_money(deal.rehab_override)}
Sender profile:
- Clase: {profile.profile_class} - {PROFILE_DESCRIPTIONS.get(profile.profile_class, "no especificado")}
- FICO: {_none_marker(profile.fico)}
- Nacionalidad: {_none_marker(profile.nationality)}
- Experiencia: {_none_marker(profile.experience_deals)} deals previos
- Capital ready: {_money(profile.capital_available)}{lender_context}
Genera el email listo para copiar y pegar.
RECORDATORIO CRITICO: NO inventes datos que NO te di arriba. Si el perfil dice 'no provisto' para FICO, NO incluyas FICO en el email. Mismo para LLC seasoning, experiencia, capital, etc.
"""