feat: AR-House initial commit
This commit is contained in:
+299
@@ -0,0 +1,299 @@
|
||||
"""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', []))}")
|
||||
Reference in New Issue
Block a user