feat: AR-House initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Utilidades compartidas del módulo location_agent."""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Geocoder para location_agent.
|
||||
|
||||
Wrapper sobre el Census Geocoder existente en data_fetchers/census_geocode.py.
|
||||
Si falla Census, intenta Nominatim (OpenStreetMap) como fallback gratuito.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from data_fetchers.census_geocode import fetch_geocode
|
||||
from data_fetchers.base import FetcherError, USER_AGENT, DEFAULT_TIMEOUT
|
||||
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
|
||||
|
||||
def geocode(address: str) -> dict:
|
||||
"""Geocodifica una dirección USA. Census primero, Nominatim como fallback.
|
||||
|
||||
Returns dict con: address, lat, lon, city, state, zip, county,
|
||||
county_fips, state_fips, tract_geoid, source
|
||||
Raises ValueError si no se puede geocodificar.
|
||||
"""
|
||||
# 1. Census Geocoder (preferido — devuelve tract FIPS)
|
||||
try:
|
||||
result = fetch_geocode(address)
|
||||
if result.get("lat") and result.get("lng"):
|
||||
return {
|
||||
"address": result.get("matched_address", address),
|
||||
"lat": float(result["lat"]),
|
||||
"lon": float(result["lng"]),
|
||||
"city": result.get("city", ""),
|
||||
"state": result.get("state", ""),
|
||||
"zip": result.get("zip", ""),
|
||||
"county": result.get("county_name", ""),
|
||||
"county_fips": result.get("county_fips", ""),
|
||||
"state_fips": result.get("state_fips", ""),
|
||||
"tract_geoid": result.get("tract_geoid", ""),
|
||||
"source": "census",
|
||||
}
|
||||
except (FetcherError, Exception):
|
||||
pass
|
||||
|
||||
# 2. Nominatim fallback
|
||||
try:
|
||||
params = {
|
||||
"q": address,
|
||||
"format": "json",
|
||||
"addressdetails": 1,
|
||||
"limit": 1,
|
||||
"countrycodes": "us",
|
||||
}
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
time.sleep(1)
|
||||
r = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=DEFAULT_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
results = r.json()
|
||||
if results:
|
||||
m = results[0]
|
||||
addr = m.get("address", {})
|
||||
return {
|
||||
"address": m.get("display_name", address),
|
||||
"lat": float(m["lat"]),
|
||||
"lon": float(m["lon"]),
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village", ""),
|
||||
"state": addr.get("state", ""),
|
||||
"zip": addr.get("postcode", ""),
|
||||
"county": addr.get("county", ""),
|
||||
"county_fips": "",
|
||||
"state_fips": "",
|
||||
"tract_geoid": "",
|
||||
"source": "nominatim",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError(f"No se pudo geocodificar: {address!r}")
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Cliente Ollama para síntesis narrativa de location_agent.
|
||||
|
||||
Solo se usa para texto — los scores son determinísticos.
|
||||
|
||||
Env vars:
|
||||
OLLAMA_MODEL: modelo (default: llama3.1:8b)
|
||||
OLLAMA_HOST: URL del servidor (default: http://localhost:11434)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def _model() -> str:
|
||||
return os.getenv("OLLAMA_MODEL", "llama3.1:8b")
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(f"{os.getenv('OLLAMA_HOST', 'http://localhost:11434')}/api/tags", timeout=3)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def analyze_section(data: dict, section: str, address: str) -> str:
|
||||
"""Narrativa de 2-3 párrafos para una sección del reporte."""
|
||||
if not is_available():
|
||||
return _auto_summary(data, section)
|
||||
try:
|
||||
import ollama
|
||||
prompt = (
|
||||
f"Analiza los siguientes datos de {section} para la ubicación: {address}\n\n"
|
||||
"Sé objetivo y profesional. 2-3 párrafos. Solo hechos relevantes para inversión "
|
||||
"inmobiliaria. Sin recomendaciones de compra. Responde en español.\n\n"
|
||||
f"Datos:\n{json.dumps(data, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
resp = ollama.chat(
|
||||
model=_model(),
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": 0.3},
|
||||
)
|
||||
return resp["message"]["content"].strip()
|
||||
except Exception as e:
|
||||
return _auto_summary(data, section) + f"\n\n[Ollama no disponible: {e}]"
|
||||
|
||||
|
||||
def executive_summary(all_scores: dict, address: str, overall: int) -> dict:
|
||||
"""Genera resumen ejecutivo. Devuelve {summary, strengths, weaknesses}."""
|
||||
if not is_available():
|
||||
return _auto_executive(all_scores, overall)
|
||||
try:
|
||||
import ollama
|
||||
scores_txt = "\n".join(f" - {k}: {v}/100" for k, v in all_scores.items())
|
||||
prompt = (
|
||||
f"Resumen ejecutivo de inteligencia de ubicación para inversión inmobiliaria.\n"
|
||||
f"Dirección: {address}\nScore General: {overall}/100\n\nScores:\n{scores_txt}\n\n"
|
||||
"Proporciona en JSON:\n"
|
||||
'{"summary": "3-5 párrafos objetivos", '
|
||||
'"strengths": ["fortaleza 1","fortaleza 2","fortaleza 3"], '
|
||||
'"weaknesses": ["debilidad 1","debilidad 2","debilidad 3"]}\n'
|
||||
"Sin recomendaciones de compra. En español."
|
||||
)
|
||||
resp = ollama.chat(
|
||||
model=_model(),
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": 0.3},
|
||||
format="json",
|
||||
)
|
||||
parsed = json.loads(resp["message"]["content"])
|
||||
return {
|
||||
"summary": parsed.get("summary", ""),
|
||||
"strengths": parsed.get("strengths", [])[:3],
|
||||
"weaknesses": parsed.get("weaknesses", [])[:3],
|
||||
}
|
||||
except Exception as e:
|
||||
r = _auto_executive(all_scores, overall)
|
||||
r["summary"] += f"\n[Ollama no disponible: {e}]"
|
||||
return r
|
||||
|
||||
|
||||
def _auto_summary(data: dict, section: str) -> str:
|
||||
if not data:
|
||||
return f"Datos de {section} no disponibles."
|
||||
lines = [f"Resumen automático — {section}:"]
|
||||
for k, v in list(data.items())[:8]:
|
||||
if isinstance(v, (str, int, float, bool)) and v not in ("", None):
|
||||
lines.append(f" • {k}: {v}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _auto_executive(scores: dict, overall: int) -> dict:
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
return {
|
||||
"summary": f"Score general: {overall}/100. Active Ollama para análisis narrativo detallado.",
|
||||
"strengths": [f"{k}: {v}/100" for k, v in ranked[:3]],
|
||||
"weaknesses": [f"{k}: {v}/100" for k, v in ranked[-3:]],
|
||||
}
|
||||
Reference in New Issue
Block a user