"""Property value fetcher para Wave 2 (ValueEstimator). Objetivo: estimar valor real de una propiedad combinando: 1. Tax assessed value (gratis, county-specific scraping) 2. Comparables recently sold (Firecrawl, MAX 5 — OPT-IN para no quemar credits) 3. Deductions automaticas por edad del inmueble (AC, roof, plumbing, panel) OUTPUT consolidado: { "listing_price": int, "tax_assessed_value": int | None, "comps_used": [{address, sold_price, sold_date, sqft, $/sqft}, ...], "estimated_value": {"low": int, "mid": int, "high": int, "confidence": str}, "price_per_sqft_comps_avg": float | None, "price_per_sqft_subject": float, "overpriced_pct": float | None, "inflation_score": float, # 0-10 "deductions": {"ac": int, "roof": int, "plumbing": int, "panel": int, "total": int}, "market_trend": {"direction": str, "evidence": str}, "sources_used": [...], "fetch_errors": [...], } FAIL-SOFT: - Sin Firecrawl key o ENABLE_FIRECRAWL_COMPS=false → comps_used=[], confidence baja - Sin tax assessed → tax_assessed_value=None, fallback a comps - Sin nada → estimacion basada SOLO en deductions vs listing """ from __future__ import annotations import os import re from datetime import datetime, timezone from typing import Optional from .base import FetcherError, USER_AGENT, DEFAULT_TIMEOUT # ─── Deductions por edad del inmueble (Florida real estate norms) ────────── DEDUCTION_AC = 6_000 # AC central viejo (<2010) si no hay evidencia de uno nuevo DEDUCTION_ROOF = 10_000 # Roof shingle viejo (<2005) DEDUCTION_PLUMBING_POLYBUTYLENE = 12_000 # Polybutylene plumbing risk (1978-1995 FL) DEDUCTION_ELECTRICAL_PANEL = 5_000 # Federal Pacific / Zinsco panels (<1990) # ─── Keywords para detectar items renovados (suprimir deduccion correspondiente) ── # Bug fix 2026-05-15: el sistema antes solo chequeaba "new ac"/"ac nuevo". # Ahora detecta variantes reales que aparecen en listings: "BRAND NEW ROOF", # "Updated/Remodeled", "Fully updated throughout", "AC replaced 2023", etc. NEW_ITEM_KEYWORDS = { "ac": [ "new ac", "new a/c", "new hvac", "new a.c.", "new air conditioning", "ac replaced", "a/c replaced", "hvac replaced", "ac unit replaced", "new air handler", "ac nuevo", "a/c nuevo", "hvac nuevo", "ac (20", "ac 20", "a/c (20", # "AC (2023)" or "AC 2023" "newer ac", "newer a/c", "newer hvac", "recently replaced ac", "recently replaced a/c", "recently replaced hvac", ], "roof": [ "new roof", "brand new roof", "brand-new roof", "newer roof", "roof replaced", "roof recently replaced", "recent roof", "roof 20", "roof (20", "new shingles", "metal roof installed", "roof installed 20", "roof nuevo", "techo nuevo", "replaced roof", "roof was replaced", "roof replacement", ], "plumbing": [ "re-piped", "repiped", "re piped", "pex plumbing", "new plumbing", "plumbing replaced", "plumbing nuevo", "fully repiped", "copper plumbing", "plumbing updated", "new pipes", ], "panel": [ "new panel", "panel upgraded", "panel replaced", "new electrical", "electrical updated", "200 amp", "new wiring", "rewired", "panel nuevo", "panel electrico nuevo", "upgraded electrical", ], } # Keywords globales que indican "renovada completa" → CERO deducciones RENOVATED_GLOBAL_KEYWORDS = [ "updated/remodeled", # Zillow explicit condition tag "fully updated", "fully renovated", "completely renovated", "completely remodeled", "move-in ready", "move in ready", "turnkey", "turn key", "turn-key", "totally renovated", "totally updated", "renovacion completa", "completamente renovada", "lista para mudarse", "newly renovated", "newly remodeled", "fully remodeled", ] # Keywords de condition_status que indican propiedad renovada RENOVATED_CONDITION_VALUES = { "updated/remodeled", "remodeled", "renovated", "updated", "new construction", "newly built", } def _matches_any_keyword(text: str, keywords: list[str]) -> Optional[str]: """Returns first matched keyword (case-insensitive substring), or None.""" if not text: return None text_lower = text.lower() for kw in keywords: if kw.lower() in text_lower: return kw return None # ─── Comps estimation ────────────────────────────────────────────────────── COMPS_MAX_COUNT = 5 COMPS_LOOKBACK_DAYS = 180 COMPS_SQFT_TOLERANCE_PCT = 0.15 PRICE_LOW_PCT = 0.92 # estimated_value['low'] = mid * 0.92 PRICE_HIGH_PCT = 1.08 # estimated_value['high'] = mid * 1.08 def calculate_age_deductions( year_built: int, photo_findings_text: str = "", listing_description: str = "", condition_status: str = "", features_special: Optional[list] = None, ) -> dict: """Deducciones automaticas segun edad de la propiedad. BUG FIX 2026-05-15: antes solo chequeaba keywords minimos en photo_findings_text. Ahora respeta condition_status, listing_description, y features_special tags. Args: year_built: año de construccion. Si <= 0 → ZERO deductions. photo_findings_text: output del PhotoInspector (puede mencionar AC nuevo, etc.) listing_description: full description del listing Zillow/Realtor condition_status: Zillow "condition" tag (e.g. "Updated/Remodeled", "Original") features_special: array de tags del "What's special" Zillow box (e.g. ["BRAND NEW ROOF", "Fresh paint", "NEW AC"]) Returns: {ac, roof, plumbing, panel, total, _skipped_global: bool, _suppressed_items: [str], _reasons: {item: kw_matched}} Si condition_status indica renovada O description tiene keywords globales → TODO 0, _skipped_global=True. Per-item: si features_special O description menciona NEW ROOF/AC/etc, suprime esa deduccion especifica. """ deductions = {"ac": 0, "roof": 0, "plumbing": 0, "panel": 0} suppressed: list[str] = [] reasons: dict[str, str] = {} # Combinar todos los textos en uno solo para keyword matching combined_text = " ".join([ photo_findings_text or "", listing_description or "", " ".join(features_special or []), ]) # 1. CHECK GLOBAL: si condition status es renovada → CERO deducciones cs_lower = (condition_status or "").lower().strip() if cs_lower in RENOVATED_CONDITION_VALUES: return { **deductions, "total": 0, "_skipped_global": True, "_skip_reason": f"condition_status='{condition_status}' (Zillow tag)", "_suppressed_items": list(deductions.keys()), "_reasons": {k: f"condition={condition_status}" for k in deductions.keys()}, } # 2. CHECK GLOBAL: si description o features mencionan "fully updated"/"move-in ready" global_kw = _matches_any_keyword(combined_text, RENOVATED_GLOBAL_KEYWORDS) if global_kw: return { **deductions, "total": 0, "_skipped_global": True, "_skip_reason": f"keyword '{global_kw}' detected in listing", "_suppressed_items": list(deductions.keys()), "_reasons": {k: f"keyword:{global_kw}" for k in deductions.keys()}, } # 3. PER-ITEM: aplicar deduccion solo si edad gatilla Y no hay keyword item-specific if not year_built or year_built <= 0: deductions["total"] = 0 deductions["_skipped_global"] = False deductions["_suppressed_items"] = [] deductions["_reasons"] = {} return deductions # AC: edad <2010 y NO hay keyword "new ac" if year_built < 2010: ac_kw = _matches_any_keyword(combined_text, NEW_ITEM_KEYWORDS["ac"]) if ac_kw: suppressed.append("ac") reasons["ac"] = f"keyword:{ac_kw}" else: deductions["ac"] = DEDUCTION_AC # Roof: edad <2005 y NO hay keyword "new roof" if year_built < 2005: roof_kw = _matches_any_keyword(combined_text, NEW_ITEM_KEYWORDS["roof"]) if roof_kw: suppressed.append("roof") reasons["roof"] = f"keyword:{roof_kw}" else: deductions["roof"] = DEDUCTION_ROOF # Plumbing polybutylene: edad 1978-1995 y NO hay keyword "repiped" if 1978 <= year_built <= 1995: pl_kw = _matches_any_keyword(combined_text, NEW_ITEM_KEYWORDS["plumbing"]) if pl_kw: suppressed.append("plumbing") reasons["plumbing"] = f"keyword:{pl_kw}" else: deductions["plumbing"] = DEDUCTION_PLUMBING_POLYBUTYLENE # Electrical panel: edad <1990 y NO hay keyword "new panel" if year_built < 1990: panel_kw = _matches_any_keyword(combined_text, NEW_ITEM_KEYWORDS["panel"]) if panel_kw: suppressed.append("panel") reasons["panel"] = f"keyword:{panel_kw}" else: deductions["panel"] = DEDUCTION_ELECTRICAL_PANEL deductions["total"] = sum(v for k, v in deductions.items() if k in ("ac", "roof", "plumbing", "panel")) deductions["_skipped_global"] = False deductions["_suppressed_items"] = suppressed deductions["_reasons"] = reasons return deductions # ═══════════════════════════════════════════════════════════════════════════ # Tax Assessed Value (county-specific scrapers) # ═══════════════════════════════════════════════════════════════════════════ def fetch_tax_assessed_miami_dade(address: str) -> Optional[dict]: """Stub para scraper Miami-Dade property appraiser. TODO Wave 2 follow-up: implementar Playwright scraping de: https://www.miamidade.gov/pa/property_search.asp Steps: 1. Search by address 2. Parse results, click property card 3. Extract assessed_value, market_value, sale_history Tiempo estimado: ~30s por lookup. Cache 30 dias. Por ahora retorna None — el agente trabaja con comps + deductions sin tax assessed. """ return None # Set explicito de counties que TIENEN scraper real (no stub). # Hoy: Broward funciona via pa_broward.py (full PA record extraction). # Pendiente: Miami-Dade, Palm Beach, Orange, Hillsborough, Pinellas... (custom adapters) # qPublic (~30 rurales) — bloqueado por Cloudflare _TAX_ASSESSED_IMPLEMENTED_COUNTIES: set[str] = { "broward", } def is_tax_assessed_supported(county_name: Optional[str], state: Optional[str]) -> bool: """True si tenemos scraper real para este county. False si es stub o no esta. El orchestrator usa esto para distinguir 'no implementado' (decisión nuestra) de 'not found' (buscamos y no estaba). NO mentir al usuario. """ if not county_name or state != "FL": return False cname = county_name.lower().replace(" county", "").strip() return cname in _TAX_ASSESSED_IMPLEMENTED_COUNTIES def fetch_tax_assessed( address: str, county_name: Optional[str], state: Optional[str], parcel_id: Optional[str] = None, ) -> Optional[dict]: """Router por condado para Property Appraiser data. Args: address: street address (used by counties whose adapter supports address search) county_name: e.g. "Broward", "Miami-Dade" state: must be "FL" today parcel_id: county-specific folio number (required for Broward; preferred for all counties since address matching is often fuzzy on PA sites) Returns: Rich dict with legacy keys (assessed_value, market_value, year_built, sqft, owner_name, source) plus extended fields when adapter supports them (sales_history, mailing_address, tax_breakdown, photo_url, etc.). None if county not implemented OR adapter returned empty result. Use is_tax_assessed_supported() to distinguish "not implemented" vs "not found". """ if not is_tax_assessed_supported(county_name, state): return None cname = (county_name or "").lower().replace(" county", "").strip() if cname == "broward": if not parcel_id: # Broward needs folio — we can't do reliable address search yet return None return _fetch_broward(parcel_id) if "miami-dade" in cname or "miami dade" in cname: return fetch_tax_assessed_miami_dade(address) # Defensive: not reachable if is_tax_assessed_supported is in sync return None def _fetch_broward(parcel_id: str) -> Optional[dict]: """Broward adapter: pa_broward.py rich record → legacy contract + extensions.""" try: from data_fetchers.pa_broward import fetch_broward_pa_record except ImportError: return None rec = fetch_broward_pa_record(parcel_id) if not rec or rec.get("errors") and not rec.get("just_value_current"): # Broward adapter failed AND no fallback data → return None return None cy = rec.get("current_year", {}) ly = rec.get("last_year", {}) # Concatenate owner_name + owner_name_2 if continuation exists (e.g. "BANK OF AMERICA NA TRSTEE" + "% CORP REAL ESTATE ASSMT") owner_full = rec.get("owner_name", "") or "" if rec.get("owner_name_2"): owner_full = f"{owner_full} {rec['owner_name_2']}".strip() # Parse beds/baths from "1 / 3 / 2.50" format (units/beds/baths) beds = baths = None ubb = (rec.get("units_beds_baths") or "").split("/") if len(ubb) >= 3: try: beds_raw = ubb[1].strip() beds = int(beds_raw) if beds_raw and beds_raw.replace(".", "").isdigit() else None except (ValueError, IndexError): pass try: baths_raw = ubb[2].strip() baths = float(baths_raw) if baths_raw and baths_raw.replace(".", "").isdigit() else None except (ValueError, IndexError): pass # ─── Legacy contract (back-compat with existing orchestrator/LLM payload) ── return { # Required legacy keys "assessed_value": cy.get("assessed_value") or ly.get("assessed_value"), "market_value": cy.get("just_value") or ly.get("just_value"), "just_value": cy.get("just_value") or ly.get("just_value"), "year_built": rec.get("year_built"), "sqft": rec.get("under_air_sqft") or rec.get("adj_bldg_sqft"), "beds": beds, "baths": baths, "owner_name": owner_full, "source": "bcpa.net", # ─── Extended fields (Property Snapshot Report inputs) ──────────────── "folio_number": rec.get("folio_number"), "mailing_address": rec.get("mailing_address"), "situs_address": rec.get("situs_address"), "neighborhood": rec.get("neighborhood"), "use_code": rec.get("use_code"), "millage_code": rec.get("millage_code"), "legal_description": rec.get("legal_description"), "adj_bldg_sqft": rec.get("adj_bldg_sqft"), "under_air_sqft": rec.get("under_air_sqft"), "effective_year": rec.get("effective_year"), "homestead_active": rec.get("homestead_active", False), "taxes_paid_last_year": ly.get("taxes_paid"), "tax_year_last": ly.get("tax_year"), "tax_year_current": cy.get("tax_year"), "current_year_values": cy, "last_year_values": ly, "two_years_ago_values": rec.get("two_years_ago", {}), "tax_breakdown": rec.get("tax_breakdown", {}), "sales_history": rec.get("sales_history", []), "photo_url": rec.get("photo_url"), "source_url": rec.get("source_url"), "source_api_url": rec.get("source_api_url"), "fetched_at": rec.get("fetched_at"), } # ═══════════════════════════════════════════════════════════════════════════ # Firecrawl comps (OPT-IN para no quemar credits) # ═══════════════════════════════════════════════════════════════════════════ def _firecrawl_enabled() -> bool: """Check env flag + key presence.""" flag = os.getenv("ENABLE_FIRECRAWL_COMPS", "false").lower() == "true" has_key = bool(os.getenv("FIRECRAWL_API_KEY", "").strip()) return flag and has_key def fetch_zillow_comps( zip_code: str, beds: int, baths: float, sqft: int, max_count: int = COMPS_MAX_COUNT, ) -> tuple[list[dict], list[str]]: """Fetch recently sold comps via Firecrawl scrape de Zillow. Returns (comps_list, errors). Comps list capped at max_count. Cada comp: {address, sold_price, sold_date_text, sqft, price_per_sqft} OPT-IN: requiere ENABLE_FIRECRAWL_COMPS=true en .env. """ errors: list[str] = [] if not _firecrawl_enabled(): errors.append( "Firecrawl comps deshabilitado. Setear ENABLE_FIRECRAWL_COMPS=true en .env para activar." ) return [], errors try: from firecrawl import FirecrawlApp except ImportError as e: errors.append(f"firecrawl-py no importable: {e}") return [], errors api_key = os.getenv("FIRECRAWL_API_KEY", "").strip() if not api_key: errors.append("FIRECRAWL_API_KEY ausente en .env") return [], errors # Zillow recently sold URL para ZIP url = f"https://www.zillow.com/homes/recently_sold/{zip_code}_rb/" try: app = FirecrawlApp(api_key=api_key) # Firecrawl SDK v2+: .scrape() (renamed from legacy .scrape_url()). # Returns Document object with .markdown attribute on success. result = app.scrape(url, formats=["markdown"]) if not result or not hasattr(result, "markdown"): errors.append("Firecrawl devolvio resultado vacio") return [], errors md = result.markdown or "" except Exception as e: errors.append(f"Firecrawl scrape error: {e}") return [], errors # Parser best-effort del markdown de Zillow. # Buscar bloques con: $price + sqft + address + sold date # Patrones tipicos en markdown de Zillow recently sold: # "$485,000" ... "1,450 sqft" ... "123 Main St" ... "Sold X/Y/Z" comps = _parse_zillow_markdown(md, beds=beds, baths=baths, sqft_target=sqft) if not comps: errors.append("Firecrawl OK pero parser no extrajo comps (Zillow cambio formato?)") return comps[:max_count], errors def _parse_zillow_markdown(md: str, beds: int, baths: float, sqft_target: int) -> list[dict]: """Best-effort parser de Zillow markdown. Extrae bloques con price + sqft + address. Tolera variaciones. """ comps = [] # Buscar todos los matches de precio + sqft cercanos # Pattern: $XXX,XXX (con o sin centavos) seguido en proximidad de "X,XXX sqft" o "X bd" price_pattern = re.compile(r"\$([\d,]{3,9})", re.IGNORECASE) sqft_pattern = re.compile(r"([\d,]{3,5})\s*sq(?:\.|uare)?\s*ft", re.IGNORECASE) sold_pattern = re.compile(r"(sold|vendido)[\s:]+([0-9/.-]+)", re.IGNORECASE) bed_pattern = re.compile(r"([\d.]+)\s*(?:bd|bed)", re.IGNORECASE) # Segmentar md en bloques de ~500 chars que probablemente contengan 1 listing segments = re.split(r"\n\n+|---+", md) for seg in segments: if len(seg) < 50 or len(seg) > 2000: continue prices = price_pattern.findall(seg) sqfts = sqft_pattern.findall(seg) if not prices or not sqfts: continue try: price = int(prices[0].replace(",", "")) sqft = int(sqfts[0].replace(",", "")) except (ValueError, IndexError): continue # Filtro: precio razonable para single home if price < 30_000 or price > 5_000_000: continue if sqft < 400 or sqft > 8_000: continue # Filtro: sqft cerca del subject (+/- COMPS_SQFT_TOLERANCE_PCT) if sqft_target > 0: ratio = sqft / sqft_target if ratio < (1 - COMPS_SQFT_TOLERANCE_PCT) or ratio > (1 + COMPS_SQFT_TOLERANCE_PCT): continue sold_match = sold_pattern.search(seg) sold_date = sold_match.group(2) if sold_match else "?" bed_match = bed_pattern.search(seg) comp_beds = bed_match.group(1) if bed_match else "?" # Address best-effort: primera linea o primer link addr_match = re.search(r"\[([^\]]+(?:St|Ave|Rd|Dr|Ln|Way|Blvd|Ct|Ter|Pl)[^\]]*)\]", seg, re.IGNORECASE) address = addr_match.group(1) if addr_match else "(direccion no parseada)" comps.append({ "address": address, "sold_price": price, "sold_date": sold_date, "sqft": sqft, "beds_text": comp_beds, "price_per_sqft": round(price / sqft, 2) if sqft > 0 else 0, }) return comps # ═══════════════════════════════════════════════════════════════════════════ # Estimacion de valor y inflation score # ═══════════════════════════════════════════════════════════════════════════ def estimate_value_from_comps(comps: list[dict], subject_sqft: int) -> tuple[Optional[int], Optional[float]]: """Calcula valor estimado mid + $/sqft promedio de comps.""" if not comps or subject_sqft <= 0: return None, None prices_per_sqft = [c["price_per_sqft"] for c in comps if c.get("price_per_sqft", 0) > 0] if not prices_per_sqft: return None, None avg_ppsqft = sum(prices_per_sqft) / len(prices_per_sqft) estimated_mid = int(avg_ppsqft * subject_sqft) return estimated_mid, round(avg_ppsqft, 2) def calculate_inflation_score(listing_price: float, estimated_mid: float) -> float: """Score 0-10 de cuanto el listing esta sobre el valor estimado. 0 = listing al valor o por debajo (subvaluado) 5 = listing 10% sobre valor 10 = listing 30%+ sobre valor (gravemente inflado) """ if estimated_mid <= 0: return 5.0 # default medium overpriced = (listing_price - estimated_mid) / estimated_mid if overpriced <= 0: return 0.0 if overpriced >= 0.30: return 10.0 return round(overpriced / 0.30 * 10, 1) # ═══════════════════════════════════════════════════════════════════════════ # API publica # ═══════════════════════════════════════════════════════════════════════════ def fetch_property_value( *, address: str, listing_price: float, sqft: int, beds: int, baths: float, year_built: int, zip_code: Optional[str] = None, county_name: Optional[str] = None, state: Optional[str] = None, photo_findings_text: str = "", listing_description: str = "", condition_status: str = "", features_special: Optional[list] = None, include_firecrawl_comps: Optional[bool] = None, ) -> dict: """Entry point. Combina tax assessed + comps + deductions en un dict consolidado. Bug fix 2026-05-15: ahora acepta listing_description, condition_status, features_special para que calculate_age_deductions pueda detectar listings renovados (e.g. condition='Updated/Remodeled' o description='Fully updated throughout, BRAND NEW ROOF, NEW AC') y suprimir deducciones falsas. include_firecrawl_comps: None → usa el flag ENABLE_FIRECRAWL_COMPS de .env (default false) True → fuerza llamada a Firecrawl (consume credits) False → omite Firecrawl """ fetched_at = datetime.now(timezone.utc).isoformat() sources_used: list[str] = [] errors: list[str] = [] # 1. Deductions por edad (siempre, gratis) — ahora respeta condition + keywords deductions = calculate_age_deductions( year_built=year_built, photo_findings_text=photo_findings_text, listing_description=listing_description, condition_status=condition_status, features_special=features_special, ) if deductions["total"] > 0: sources_used.append("Deductions por edad (heuristica FL)") elif deductions.get("_skipped_global"): sources_used.append( f"Deductions SKIPPED (renovated: {deductions.get('_skip_reason', '?')})" ) # 2. Tax assessed (stub Miami-Dade por ahora) tax_assessed_data = fetch_tax_assessed(address, county_name, state) tax_assessed_value = None if tax_assessed_data: tax_assessed_value = tax_assessed_data.get("assessed_value") sources_used.append(f"Tax assessed ({county_name})") else: errors.append(f"Tax assessed no disponible para {county_name or '?'} (scraper pendiente Wave 2 follow-up)") # 3. Firecrawl comps (opt-in) if include_firecrawl_comps is None: do_firecrawl = _firecrawl_enabled() else: do_firecrawl = include_firecrawl_comps comps: list[dict] = [] if do_firecrawl and zip_code: comps, comp_errors = fetch_zillow_comps(zip_code, beds, baths, sqft) errors.extend(comp_errors) if comps: sources_used.append(f"Zillow recently sold ({len(comps)} comps via Firecrawl)") # 4. Calcular valor estimado estimated_mid_from_comps, avg_ppsqft = estimate_value_from_comps(comps, sqft) price_per_sqft_subject = round(listing_price / sqft, 2) if sqft > 0 else 0 # Combinar tax_assessed + comps + deductions para mid estimate candidates_mid = [] if estimated_mid_from_comps: candidates_mid.append(estimated_mid_from_comps) if tax_assessed_value: # Tax assessed en FL suele ser ~85% del market value candidates_mid.append(int(tax_assessed_value / 0.85)) if candidates_mid: estimated_mid = int(sum(candidates_mid) / len(candidates_mid)) - deductions["total"] else: # Fallback: listing - deductions, con muy low confidence estimated_mid = max(0, int(listing_price) - deductions["total"]) estimated_mid = max(estimated_mid, 1) # nunca zero estimated_low = int(estimated_mid * PRICE_LOW_PCT) estimated_high = int(estimated_mid * PRICE_HIGH_PCT) # Confidence segun fuentes disponibles if comps and tax_assessed_value: confidence = "high" elif comps or tax_assessed_value: confidence = "medium" else: confidence = "low" overpriced_pct = None if estimated_mid > 0: overpriced_pct = round((listing_price - estimated_mid) / estimated_mid * 100, 1) inflation_score = calculate_inflation_score(listing_price, estimated_mid) return { "listing_price": int(listing_price), "tax_assessed_value": tax_assessed_value, "comps_used": comps, "estimated_value": { "low": estimated_low, "mid": estimated_mid, "high": estimated_high, "confidence": confidence, }, "price_per_sqft_comps_avg": avg_ppsqft, "price_per_sqft_subject": price_per_sqft_subject, "overpriced_pct": overpriced_pct, "inflation_score": inflation_score, "deductions": deductions, "market_trend": { "direction": "unknown", "evidence": "Para detectar tendencia requiere historial de comps (no implementado en MVP)", }, "sources_used": sources_used, "fetch_errors": errors, "firecrawl_used": do_firecrawl and bool(comps), "fetched_at": fetched_at, }