File size: 8,959 Bytes
fbd808d 39e1acf fbd808d 76398fd fbd808d 76398fd fbd808d 39e1acf 76398fd 39e1acf fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd fbd808d 39e1acf fbd808d 76398fd fbd808d 76398fd 39e1acf 76398fd fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd 39e1acf 76398fd 39e1acf 76398fd 39e1acf fbd808d 76398fd 39e1acf fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd fbd808d 39e1acf fbd808d 76398fd fbd808d 76398fd fbd808d 39e1acf fbd808d 76398fd fbd808d 76398fd fbd808d 2e3b72e fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd fbd808d 76398fd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | """
services/tool_compressor.py
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Token-efficient tool output compression and the geocode_address_handler.
compress_tool_output(): Converts raw RPC dicts into compact strings for LLM context.
geocode_address_handler(): Resolves addresses to lat/lng via Mapbox.
"""
import csv
import io
import json
import requests
from services.observability import setup_logger
from config import MAPBOX_ACCESS_TOKEN
logger = setup_logger(__name__)
_MAX_ROWS = 30
_MAX_RESULT_CHARS = 2000
_LIST_KEYS = ("items", "results", "compared", "rate_history", "ranked_properties", "newly_added", "untracked_or_removed")
def _clean_value(val):
"""Truncates ISO datetime strings to date-only format for token economy."""
if isinstance(val, str) and len(val) >= 19 and "T" in val and (val.endswith("Z") or "+" in val):
return val[:10]
return val
def _extract_raw_data(func_name: str, db_result: dict) -> tuple[str | None, any]:
"""Validates status and extracts the payload, handling scalars early."""
if db_result.get("status") != "success":
msg = db_result.get("message", "unknown error")
return f"Tool '{func_name}' error: {msg}", None
data = db_result.get("data") if "data" in db_result else {k: v for k, v in db_result.items() if k != "status"}
if not data:
return f"Tool '{func_name}': no data returned.", None
if isinstance(data, (str, int, float, bool)):
return f"Tool '{func_name}' result: {data}", None
return None, data
def _find_list_envelope(data: dict) -> tuple[str | None, list | None, dict]:
"""Inspects a dict payload for known list envelope keys and extracts metadata."""
for lk in _LIST_KEYS:
val = data.get(lk)
if isinstance(val, list):
outer_meta = {k: v for k, v in data.items() if k != lk and not isinstance(v, (list, dict))}
return lk, val, outer_meta
return None, None, {}
def _unwrap_payload(func_name: str, db_result: dict) -> tuple[str | None, list | None, dict]:
"""Extracts list data and outer metadata from raw RPC result dictionary."""
early_err, data = _extract_raw_data(func_name, db_result)
if early_err is not None:
return early_err, None, {}
outer_metadata = {}
if isinstance(data, dict):
found_key, list_val, outer_metadata = _find_list_envelope(data)
if not found_key:
return f"Tool '{func_name}' result:\n{json.dumps(data, default=str)}", None, {}
data = list_val
if not isinstance(data, list) or not data:
meta_prefix = f"[{', '.join(f'{k}={v}' for k, v in outer_metadata.items())}]\n" if outer_metadata else ""
return f"Tool '{func_name}':\n{meta_prefix}0 items returned.", None, {}
return None, data, outer_metadata
def _filter_and_slice_rows(func_name: str, rows: list) -> tuple[str | None, list[dict], bool]:
"""Filters out empty rows and slices list to _MAX_ROWS limit."""
data = [row for row in rows if isinstance(row, dict) and any(v is not None for v in row.values())]
if not data:
return f"Tool '{func_name}': all returned rows were empty.", [], False
if len(data) > _MAX_ROWS:
return None, data[:_MAX_ROWS], True
return None, data, False
def _find_candidate_keys(first_row: dict) -> list[str]:
"""Returns column keys to process, omitting redundant property_id when property_name exists."""
all_keys = list(first_row.keys())
has_name = "property_name" in all_keys
if has_name and "property_id" in all_keys:
return [k for k in all_keys if k != "property_id"]
return all_keys
def _hoist_metadata(data: list[dict], outer_metadata: dict) -> tuple[str, list[str]]:
"""Hoists columns with identical values across all rows into a bracketed header."""
candidate_keys = _find_candidate_keys(data[0])
hoisted = dict(outer_metadata)
row_keys = []
for key in candidate_keys:
unique_values = {str(row.get(key)) for row in data}
if len(unique_values) == 1 and data[0].get(key) is not None:
hoisted[key] = data[0].get(key)
else:
row_keys.append(key)
header_parts = [f"{k}={v}" for k, v in hoisted.items()]
header_str = f"[{', '.join(header_parts)}]\n" if header_parts else ""
active_keys = [k for k in row_keys if any(row.get(k) is not None for row in data)]
return header_str, active_keys
def _render_csv_table(data: list[dict], active_keys: list[str], header_str: str, truncated: bool) -> str:
"""Renders data rows as CSV text with optional truncation notice."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(active_keys)
for row in data:
writer.writerow([_clean_value(row.get(k)) for k in active_keys])
truncation_notice = (
f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
if truncated else ""
)
return f"{header_str}{buf.getvalue().strip()}{truncation_notice}"
def _cap_result_string(result_str: str) -> str:
"""Enforces _MAX_RESULT_CHARS character cap on formatted output."""
if len(result_str) <= _MAX_RESULT_CHARS:
return result_str
lines = result_str.split("\n")
capped = []
total = 0
for line in lines:
if total + len(line) > _MAX_RESULT_CHARS:
capped.append("[Result capped at 2000 chars. Advise user to narrow filters.]")
break
capped.append(line)
total += len(line)
return "\n".join(capped)
def compress_tool_output(func_name: str, db_result: dict) -> str:
"""Converts raw RPC dicts into compact strings for LLM context."""
early_return, data, outer_metadata = _unwrap_payload(func_name, db_result)
if early_return is not None:
return early_return
early_filter_err, valid_rows, truncated = _filter_and_slice_rows(func_name, data)
if early_filter_err is not None:
return early_filter_err
header_str, active_keys = _hoist_metadata(valid_rows, outer_metadata)
rendered = _render_csv_table(valid_rows, active_keys, header_str, truncated)
return _cap_result_string(rendered)
def _parse_geocode_feature(features: list, address: str) -> dict:
"""Extracts coordinates and formatted address from Mapbox features."""
if not features:
return {
"status": "error",
"message": f"We are unable to locate coordinates for '{address}'. Please try a more specific address.",
}
feature = features[0]
lon, lat = feature["geometry"]["coordinates"]
resolved = feature.get("properties", {}).get("full_address", address)
return {
"status": "success",
"latitude": round(lat, 6),
"longitude": round(lon, 6),
"resolved_address": resolved,
}
def geocode_address_handler(address: str) -> dict:
"""Resolves a free-text address to lat/lng via the Mapbox Geocoding API."""
if not MAPBOX_ACCESS_TOKEN:
return {
"status": "error",
"message": "We are unable to geocode addresses at this time - the mapping service is not configured.",
}
clean_addr = address.strip() if address else ""
if not clean_addr:
return {"status": "error", "message": "No address was provided to geocode."}
params = {
"q": clean_addr,
"access_token": MAPBOX_ACCESS_TOKEN,
"limit": 1,
}
from config import GEOCODE_COUNTRY_FILTER
if GEOCODE_COUNTRY_FILTER:
params["country"] = GEOCODE_COUNTRY_FILTER
try:
response = requests.get("https://api.mapbox.com/search/geocode/v6/forward", params=params, timeout=6)
response.raise_for_status()
return _parse_geocode_feature(response.json().get("features", []), clean_addr)
except requests.exceptions.Timeout:
logger.error(f"Mapbox geocode timeout for address: {clean_addr}")
return {"status": "error", "message": "Mapping service timed out. Please try again shortly."}
except requests.exceptions.HTTPError as e:
logger.error(f"Mapbox geocode HTTP error {e.response.status_code} for: {clean_addr}")
return {"status": "error", "message": "Mapping service returned an error. Please try again later."}
except requests.exceptions.RequestException as e:
logger.error(f"Mapbox geocode request failed for '{clean_addr}': {e}")
return {"status": "error", "message": "Unable to reach the mapping service. Please try again later."}
except (KeyError, IndexError, ValueError) as e:
logger.error(f"Mapbox geocode parsing error for '{clean_addr}': {e}")
return {"status": "error", "message": f"Unable to parse the location for '{clean_addr}'. Please try a more specific address."}
|