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."}