"""dd_agent.py — DueDiligenceCoordinator agent synthesis layer. Toma TODOS los inputs publicos recopilados sobre una propiedad (PA + court + financial + flip signals + environmental + market) y los sintetiza via DueDiligenceCoordinator LLM en un reporte estructurado JSON. ESTE es el cerebro del producto $150 — sin esto, el full report es solo data dump. Con esto, el cliente paga por sintesis razonada profesional. USAGE: from dd_agent import run_due_diligence_synthesis synthesis = run_due_diligence_synthesis( pa_record=..., court_records=..., owner_classification=..., reo_signal=..., financial_analysis=..., environmental={...}, market_context={...}, listing_info={...}, ) # → {executive_summary, verdict, confidence_score, top_risks, ...} """ from __future__ import annotations import json import time from typing import Optional MODEL_NAME = "DueDiligenceCoordinator" # 240s para acomodar: ~30-60s model load primer call + 60-120s generation 2500 tokens DEFAULT_TIMEOUT_SECONDS = 240 def run_due_diligence_synthesis( *, pa_record: Optional[dict] = None, court_records: Optional[dict] = None, owner_classification: Optional[dict] = None, reo_signal: Optional[dict] = None, renovation_signal: Optional[dict] = None, financial_analysis: Optional[dict] = None, environmental: Optional[dict] = None, market_context: Optional[dict] = None, listing_info: Optional[dict] = None, status_cb: Optional[callable] = None, ) -> dict: """Run DueDiligenceCoordinator synthesis over all collected public data. Returns dict matching DueDiligenceCoordinator output schema, or dict with 'error' key if synthesis failed. """ t0 = time.perf_counter() def _log(m: str): if status_cb: status_cb(m) try: import ollama except ImportError: return {"error": "ollama package not installed"} # Build comprehensive input payload (trim to keep context manageable) user_payload = _build_input_payload( pa_record=pa_record, court_records=court_records, owner_classification=owner_classification, reo_signal=reo_signal, renovation_signal=renovation_signal, financial_analysis=financial_analysis, environmental=environmental, market_context=market_context, listing_info=listing_info, ) prompt = ( "Sintetiza este JSON de inputs publicos sobre una propiedad en el " "reporte JSON estructurado segun tu system prompt. Solo output JSON, " "sin markdown wrapper.\n\n" f"```json\n{json.dumps(user_payload, indent=2, default=str)}\n```\n" ) _log(f" Calling DueDiligenceCoordinator (qwen2.5:14b)...") try: import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: future = ex.submit( ollama.chat, model=MODEL_NAME, messages=[{"role": "user", "content": prompt}], options={"temperature": 0.15, "num_ctx": 16384, "num_predict": 1800}, format="json", # forzar JSON ) try: response = future.result(timeout=DEFAULT_TIMEOUT_SECONDS) except concurrent.futures.TimeoutError: return {"error": f"DueDiligenceCoordinator timeout after {DEFAULT_TIMEOUT_SECONDS}s"} except Exception as e: return {"error": f"ollama call failed: {type(e).__name__}: {e}"} content = response.get("message", {}).get("content", "").strip() if not content: return {"error": "DueDiligenceCoordinator returned empty content"} # Parse JSON try: # Strip markdown fences if present if content.startswith("```"): content = content.split("```", 2)[1] if content.startswith("json"): content = content[4:] content = content.strip() synthesis = json.loads(content) except json.JSONDecodeError as e: return { "error": f"JSON parse failed: {e}", "raw_output": content[:1000], } elapsed = round(time.perf_counter() - t0, 1) synthesis["_meta"] = { "model": MODEL_NAME, "elapsed_seconds": elapsed, "input_sources_present": _summarize_inputs(user_payload), } _log(f" DueDiligence synthesis done in {elapsed}s — verdict={synthesis.get('verdict','?')} confidence={synthesis.get('confidence_score','?')}") return synthesis # ════════════════════════════════════════════════════════════════════════════ # Input payload builder — trim to essential fields, keep context small # ════════════════════════════════════════════════════════════════════════════ def _build_input_payload( *, pa_record: Optional[dict] = None, court_records: Optional[dict] = None, owner_classification: Optional[dict] = None, reo_signal: Optional[dict] = None, renovation_signal: Optional[dict] = None, financial_analysis: Optional[dict] = None, environmental: Optional[dict] = None, market_context: Optional[dict] = None, listing_info: Optional[dict] = None, ) -> dict: """Build trimmed input dict for DueDiligenceCoordinator. Avoid context bloat.""" payload = {} if pa_record: # PA key fields only (skip raw + meta) payload["property_appraiser"] = { k: pa_record.get(k) for k in ( "county", "parcel_id", "owner_name", "co_owners", "mailing_address", "site_address", "owner_address_mismatch", "year_built", "effective_year_built", "sqft_heated", "sqft_total", "lot_acres", "lot_total_sqft", "bedrooms", "baths", "stories", "building_type", "use_code", "use_description", "zoning", "roof_type", "roofing_cover", "exterior_wall", "interior_wall", "just_value_current", "assessed_value_current", "exemption_current", "just_value_last", "assessed_value_last", "taxes_paid_last", "tax_year_current", "tax_year_last", "homestead_active", "homestead_amount", "subdivision", "legal_description", ) } # Trim sales history to last 8 records sales = pa_record.get("sales_history") or [] payload["property_appraiser"]["sales_history"] = sales[:8] payload["property_appraiser"]["most_recent_qualified_sale"] = pa_record.get("most_recent_qualified_sale") if court_records: payload["court_records"] = { k: court_records.get(k) for k in ( "status", "county", "plaintiff", "case_number", "lis_pendens_count", "lis_pendens", "liens_inventory", "owner_name", ) } if owner_classification: payload["owner_classification"] = { "type": owner_classification.get("type"), "category": owner_classification.get("category"), "is_lender": owner_classification.get("is_lender"), "is_government": owner_classification.get("is_government"), "is_individual": owner_classification.get("is_individual"), "is_investor_entity": owner_classification.get("is_investor_entity"), "matched_keyword": owner_classification.get("matched_keyword"), "confidence": owner_classification.get("confidence"), } if reo_signal and reo_signal.get("is_reo_opportunity"): payload["reo_signal"] = reo_signal if renovation_signal: payload["renovation_signal"] = { "is_flip_pattern": renovation_signal.get("is_flip_pattern"), "is_flip_in_progress": renovation_signal.get("is_flip_in_progress"), "evidence": renovation_signal.get("evidence"), "interpretation_es": renovation_signal.get("interpretation_es"), "value_increase_pct": renovation_signal.get("value_increase_pct"), "months_between": renovation_signal.get("months_between"), "listing_premium_pct": renovation_signal.get("listing_premium_pct"), "months_since_recent_sale": renovation_signal.get("months_since_recent_sale"), } if financial_analysis: payload["financial_analysis"] = { "max_profitable_offer": financial_analysis.get("max_profitable_offer"), "payment_table": financial_analysis.get("payment_table"), "recommendation": financial_analysis.get("recommendation"), "inputs": financial_analysis.get("inputs"), } # Only include live_in si tiene income (DTI relevant) li = financial_analysis.get("live_in_scenario") or {} if (li.get("dti_evaluation") or {}).get("monthly_income"): payload["financial_analysis"]["live_in_dti"] = li.get("dti_evaluation") if environmental: payload["environmental"] = environmental if market_context: payload["market_context"] = market_context if listing_info: payload["listing_info"] = listing_info return payload def _summarize_inputs(payload: dict) -> dict: """Return which input sources are present (for confidence calibration).""" return {k: bool(payload.get(k)) for k in ( "property_appraiser", "court_records", "owner_classification", "reo_signal", "renovation_signal", "financial_analysis", "environmental", "market_context", "listing_info", )} # ════════════════════════════════════════════════════════════════════════════ # CLI for manual testing # ════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="DueDiligenceCoordinator manual test") parser.add_argument("--deal-id", type=int, required=True, help="Deal id from deals.db") args = parser.parse_args() # Load deal + run pre_screening to get all the signals, then synthesize import sqlite3 conn = sqlite3.connect("data/deals.db") conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute("SELECT * FROM deals WHERE id=?", (args.deal_id,)) row = cur.fetchone() if not row: print(f"Deal id={args.deal_id} not found"); exit(1) deal = dict(row) conn.close() from pre_screening_orchestrator import run_pre_screening print(f"Running pre-screening for deal {args.deal_id} to gather inputs...") ps = run_pre_screening(deal=deal) print("\nRunning DueDiligenceCoordinator synthesis...") synth = run_due_diligence_synthesis( pa_record=ps.get("property_appraiser"), court_records=ps.get("court_records_raw"), owner_classification=ps.get("owner_classification"), reo_signal=ps.get("reo_signal"), renovation_signal=(ps.get("property_appraiser") or {}).get("renovation_signal"), financial_analysis=ps.get("financial_analysis"), listing_info={ "listing_price": deal.get("listing_price"), "deal_type": deal.get("deal_type"), "source": deal.get("source"), "source_url": deal.get("source_url"), }, status_cb=print, ) print() # Write to file to avoid Windows cp1252 console encoding issues out_path = f"_probe_out/dd_synth_{args.deal_id}.json" import os os.makedirs(os.path.dirname(out_path), exist_ok=True) with open(out_path, "w", encoding="utf-8") as fh: json.dump(synth, fh, indent=2, default=str, ensure_ascii=False) print(f"Synthesis saved to {out_path}") # Print ASCII-safe summary print(f" verdict: {synth.get('verdict')}") print(f" confidence_score: {synth.get('confidence_score')}") print(f" top_risks: {len(synth.get('top_risks', []))} items") bid = synth.get('bid_recommendation') or {} print(f" bid: ${bid.get('low',0):,} - ${bid.get('high',0):,} (mid ${bid.get('mid',0):,})") print(f" action_items: {len(synth.get('action_items', []))}")