1452 lines
66 KiB
Python
1452 lines
66 KiB
Python
"""
|
||
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.
|
||
"""
|