"""property_researcher.py — Reasoning loop que hace research autónomo de properties. PRINCIPIO: La IA local (Ollama PropertyResearcher) decide qué tools llamar para investigar un deal. Loop hasta que llama `finish` o hits max iterations. USO: from property_researcher import research_deal result = research_deal( deal_id=349, max_iterations=15, status_cb=print, ) # → {summary, portals_used, documents_saved, findings, iterations, elapsed_seconds} """ from __future__ import annotations import json import time import traceback from typing import Callable, Optional from agent_tools import OLLAMA_TOOL_SPECS, TOOL_DISPATCH # ──────────────────────────────────────────────────────────────────────────── # Constants # ──────────────────────────────────────────────────────────────────────────── MODEL_NAME = "PropertyResearcher" DEFAULT_MAX_ITERATIONS = 15 TOOL_TIMEOUT_SECONDS = 60 # cap per tool call # ──────────────────────────────────────────────────────────────────────────── # Main entry point # ──────────────────────────────────────────────────────────────────────────── def research_deal( deal_id: int, max_iterations: int = DEFAULT_MAX_ITERATIONS, status_cb: Optional[Callable[[str], None]] = None, ) -> dict: """Run autonomous research loop on a deal. Returns: { "deal_id": int, "summary": str, "portals_used": [str], "documents_saved": [str], "findings": dict, "iterations": int, "elapsed_seconds": float, "errors": [str], "finished_cleanly": bool, "tool_calls_log": [dict] # detailed history per iteration } """ t0 = time.perf_counter() def _log(msg: str) -> None: if status_cb: status_cb(msg) try: import ollama except ImportError: return _error_result(deal_id, "ollama package not installed", t0) # Load deal context try: from deals_db import init_db, get_deal_by_id init_db() deal = get_deal_by_id(deal_id) if not deal: return _error_result(deal_id, f"deal_id={deal_id} not found in deals.db", t0) except Exception as e: return _error_result(deal_id, f"deal load failed: {e}", t0) # Build the initial user task description task = _build_task_description(deal) _log(f"[researcher] Task: {task[:120]}") # Conversation history (multi-turn with tool results fed back) messages: list[dict] = [ {"role": "user", "content": task}, ] findings: dict = {} portals_used: list[str] = [] documents_saved: list[str] = [] errors: list[str] = [] tool_calls_log: list[dict] = [] finished_cleanly = False final_summary = "" for iteration in range(1, max_iterations + 1): _log(f"[researcher] iter {iteration}/{max_iterations}: thinking...") try: response = ollama.chat( model=MODEL_NAME, messages=messages, tools=OLLAMA_TOOL_SPECS, options={"temperature": 0.2, "num_ctx": 16384}, ) except Exception as e: errors.append(f"iter {iteration} ollama.chat failed: {e}") _log(f"[researcher] ERROR: {e}") break msg = response.get("message", {}) # Append assistant message to history messages.append({"role": "assistant", "content": msg.get("content", ""), "tool_calls": msg.get("tool_calls", [])}) tool_calls = msg.get("tool_calls", []) or [] if not tool_calls: # No more tools — model decided to finish without calling finish() content = msg.get("content", "").strip() _log(f"[researcher] no tool_calls; content={content[:200]}") if content: final_summary = content break # Execute each tool call sequentially for tc in tool_calls: fn = tc.get("function", {}) name = fn.get("name", "") args_raw = fn.get("arguments", {}) args = args_raw if isinstance(args_raw, dict) else _safe_json_loads(args_raw) _log(f"[researcher] → calling {name}({_short_args(args)})") # Special-case: finish() ends the loop if name == "finish": final_summary = args.get("summary", "") portals_used = args.get("portals_used", []) documents_saved = args.get("documents_saved", []) findings = args.get("findings", {}) finished_cleanly = True tool_calls_log.append({"iteration": iteration, "name": name, "args": args}) _log(f"[researcher] FINISH: {final_summary[:120]}") break # Auto-inject deal_id for save/download tools if missing if name in ("save_document", "download_pdf") and "deal_id" not in args: args["deal_id"] = deal_id # Execute the tool tool_result = _execute_tool(name, args) tool_calls_log.append({ "iteration": iteration, "name": name, "args": _short_args(args, max_len=300), "result": _short_result(tool_result), }) # Track outputs of interest if name == "save_document" or name == "download_pdf": if "saved_to" in tool_result: documents_saved.append(tool_result["saved_to"]) elif name == "remember_portal": if tool_result.get("ok"): portals_used.append(tool_result.get("url", "")) # Feed tool result back to the model tool_result_str = json.dumps(tool_result, default=str)[:3000] messages.append({"role": "tool", "content": tool_result_str, "name": name}) if finished_cleanly: break elapsed = time.perf_counter() - t0 return { "deal_id": deal_id, "summary": final_summary or "Research completed without explicit summary", "portals_used": portals_used, "documents_saved": documents_saved, "findings": findings, "iterations": iteration, "elapsed_seconds": round(elapsed, 1), "errors": errors, "finished_cleanly": finished_cleanly, "tool_calls_log": tool_calls_log, } # ──────────────────────────────────────────────────────────────────────────── # Helpers # ──────────────────────────────────────────────────────────────────────────── def _build_task_description(deal: dict) -> str: """Build the initial user prompt describing what to research.""" parts = [ f"Research this property deal:", f"- deal_id: {deal['id']}", f"- Address: {deal.get('address') or '?'}", f"- County: {deal.get('county') or '?'}", f"- State: {deal.get('state') or '?'}", f"- Deal type: {deal.get('deal_type') or '?'}", ] if deal.get("case_number"): parts.append(f"- Case #: {deal['case_number']}") if deal.get("parcel_id"): parts.append(f"- Parcel ID: {deal['parcel_id']}") if deal.get("listing_price"): parts.append(f"- Listing price: ${deal['listing_price']:,.0f}") parts.append("") parts.append("Goal: find and document the 3 BASIC items for pre-screening:") parts.append(" 1. Property Appraiser data (owner, assessed value, year built, sqft)") parts.append(" 2. Court records (plaintiff/defendant if foreclosure, or owner if MLS)") parts.append(" 3. Property photo (if available from PA or other free source)") parts.append("") parts.append("Save what you find via save_document. Call remember_portal for working portals.") parts.append("When done, call finish() with a summary.") return "\n".join(parts) def _execute_tool(name: str, args: dict) -> dict: """Dispatch to the actual Python function.""" fn = TOOL_DISPATCH.get(name) if not fn: return {"error": f"unknown tool: {name}"} try: # Filter args to those the function accepts (defensive) return fn(**args) except TypeError as e: return {"error": f"bad args for {name}: {e}"} except Exception as e: return {"error": f"{name} crashed: {type(e).__name__}: {e}", "trace": traceback.format_exc()[:500]} def _safe_json_loads(s) -> dict: if not s: return {} if isinstance(s, dict): return s try: return json.loads(s) except Exception: return {} def _short_args(args: dict, max_len: int = 100) -> str: s = json.dumps(args, default=str) return s[:max_len] + ("..." if len(s) > max_len else "") def _short_result(result: dict) -> dict: """Truncate large fields so logs don't explode.""" short = {} for k, v in result.items(): if isinstance(v, str) and len(v) > 200: short[k] = v[:200] + f"...({len(v)} chars)" elif isinstance(v, list) and len(v) > 5: short[k] = v[:5] + [f"...({len(v)} items)"] else: short[k] = v return short def _error_result(deal_id: int, msg: str, t0: float) -> dict: return { "deal_id": deal_id, "summary": "", "portals_used": [], "documents_saved": [], "findings": {}, "iterations": 0, "elapsed_seconds": round(time.perf_counter() - t0, 1), "errors": [msg], "finished_cleanly": False, "tool_calls_log": [], }