geoai-py 0.11.0__py2.py3-none-any.whl → 0.12.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geoai/__init__.py +1 -1
- geoai/agents/__init__.py +2 -0
- geoai/agents/geo_agents.py +377 -0
- geoai/agents/map_tools.py +1502 -0
- geoai/dinov3.py +4 -0
- geoai/geoai.py +5 -5
- geoai/utils.py +2 -1
- {geoai_py-0.11.0.dist-info → geoai_py-0.12.0.dist-info}/METADATA +42 -17
- {geoai_py-0.11.0.dist-info → geoai_py-0.12.0.dist-info}/RECORD +13 -10
- {geoai_py-0.11.0.dist-info → geoai_py-0.12.0.dist-info}/WHEEL +0 -0
- {geoai_py-0.11.0.dist-info → geoai_py-0.12.0.dist-info}/entry_points.txt +0 -0
- {geoai_py-0.11.0.dist-info → geoai_py-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {geoai_py-0.11.0.dist-info → geoai_py-0.12.0.dist-info}/top_level.txt +0 -0
geoai/__init__.py
CHANGED
geoai/agents/__init__.py
ADDED
@@ -0,0 +1,377 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import uuid
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
6
|
+
from types import SimpleNamespace
|
7
|
+
from typing import Any, Callable, Optional
|
8
|
+
|
9
|
+
import ipywidgets as widgets
|
10
|
+
import leafmap.maplibregl as leafmap
|
11
|
+
from ipyevents import Event
|
12
|
+
from IPython.display import display
|
13
|
+
from strands import Agent
|
14
|
+
from strands.models.ollama import OllamaModel
|
15
|
+
|
16
|
+
from .map_tools import MapSession, MapTools
|
17
|
+
|
18
|
+
try:
|
19
|
+
import nest_asyncio
|
20
|
+
|
21
|
+
nest_asyncio.apply()
|
22
|
+
except Exception:
|
23
|
+
pass
|
24
|
+
|
25
|
+
|
26
|
+
def _ensure_loop() -> asyncio.AbstractEventLoop:
|
27
|
+
try:
|
28
|
+
loop = asyncio.get_event_loop()
|
29
|
+
except RuntimeError:
|
30
|
+
loop = asyncio.new_event_loop()
|
31
|
+
asyncio.set_event_loop(loop)
|
32
|
+
if loop.is_closed():
|
33
|
+
loop = asyncio.new_event_loop()
|
34
|
+
asyncio.set_event_loop(loop)
|
35
|
+
return loop
|
36
|
+
|
37
|
+
|
38
|
+
class GeoAgent(Agent):
|
39
|
+
"""Geospatial AI agent with interactive mapping capabilities."""
|
40
|
+
|
41
|
+
def __init__(
|
42
|
+
self, *, model_id: str = "llama3.1", map_instance: Optional[leafmap.Map] = None
|
43
|
+
) -> None:
|
44
|
+
"""Initialize the GeoAgent.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
model_id: Ollama model identifier (default: "llama3.1").
|
48
|
+
map_instance: Optional existing map instance.
|
49
|
+
"""
|
50
|
+
self.session: MapSession = MapSession(map_instance)
|
51
|
+
self.tools: MapTools = MapTools(self.session)
|
52
|
+
|
53
|
+
# --- save a model factory we can call each turn ---
|
54
|
+
self._model_factory: Callable[[], OllamaModel] = lambda: OllamaModel(
|
55
|
+
host="http://localhost:11434", model_id=model_id
|
56
|
+
)
|
57
|
+
|
58
|
+
# build initial model (first turn)
|
59
|
+
ollama_model: OllamaModel = self._model_factory()
|
60
|
+
|
61
|
+
super().__init__(
|
62
|
+
name="Leafmap Visualization Agent",
|
63
|
+
model=ollama_model,
|
64
|
+
tools=[
|
65
|
+
# Core navigation tools
|
66
|
+
self.tools.fly_to,
|
67
|
+
self.tools.create_map,
|
68
|
+
self.tools.zoom_to,
|
69
|
+
self.tools.jump_to,
|
70
|
+
# Essential layer tools
|
71
|
+
self.tools.add_basemap,
|
72
|
+
self.tools.add_vector,
|
73
|
+
self.tools.add_raster,
|
74
|
+
self.tools.add_cog_layer,
|
75
|
+
self.tools.remove_layer,
|
76
|
+
self.tools.get_layer_names,
|
77
|
+
self.tools.set_terrain,
|
78
|
+
self.tools.remove_terrain,
|
79
|
+
self.tools.add_overture_3d_buildings,
|
80
|
+
self.tools.set_paint_property,
|
81
|
+
self.tools.set_layout_property,
|
82
|
+
self.tools.set_color,
|
83
|
+
self.tools.set_opacity,
|
84
|
+
self.tools.set_visibility,
|
85
|
+
# self.tools.save_map,
|
86
|
+
# Basic interaction tools
|
87
|
+
self.tools.add_marker,
|
88
|
+
self.tools.set_pitch,
|
89
|
+
],
|
90
|
+
system_prompt="You are a map control agent. Call tools with MINIMAL parameters only.\n\n"
|
91
|
+
+ "CRITICAL: Treat all kwargs parameters as optional parameters.\n"
|
92
|
+
+ "CRITICAL: NEVER include optional parameters unless user explicitly asks for them.\n\n"
|
93
|
+
+ "TOOL CALL RULES:\n"
|
94
|
+
+ "- zoom_to(zoom=N) - ONLY zoom parameter, OMIT options completely\n"
|
95
|
+
+ "- add_cog_layer(url='X') - NEVER include bands, nodata, opacity, etc.\n"
|
96
|
+
+ "- fly_to(longitude=N, latitude=N) - NEVER include zoom parameter\n"
|
97
|
+
+ "- add_basemap(name='X') - NEVER include any other parameters\n"
|
98
|
+
+ "- add_marker(lng_lat=[lon,lat]) - NEVER include popup or options\n\n"
|
99
|
+
+ "- remove_layer(name='X') - call get_layer_names() to get the layer name closest to"
|
100
|
+
+ "the name of the layer you want to remove before calling this tool\n\n"
|
101
|
+
+ "- add_overture_3d_buildings(kwargs={}) - kwargs parameter required by tool validation\n"
|
102
|
+
+ "FORBIDDEN: Optional parameters, string representations like '{}' or '[1,2,3]'\n"
|
103
|
+
+ "REQUIRED: Minimal tool calls with only what's absolutely necessary",
|
104
|
+
callback_handler=None,
|
105
|
+
)
|
106
|
+
|
107
|
+
def ask(self, prompt: str) -> str:
|
108
|
+
"""Send a single-turn prompt to the agent.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
prompt: The text prompt to send to the agent.
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
The agent's response as a string.
|
115
|
+
"""
|
116
|
+
_ensure_loop()
|
117
|
+
self.model = self._model_factory()
|
118
|
+
|
119
|
+
result = self(prompt)
|
120
|
+
return getattr(result, "final_text", str(result))
|
121
|
+
|
122
|
+
def show_ui(self, *, height: int = 700) -> None:
|
123
|
+
"""Display an interactive UI with map and chat interface.
|
124
|
+
|
125
|
+
Args:
|
126
|
+
height: Height of the UI in pixels (default: 700).
|
127
|
+
"""
|
128
|
+
|
129
|
+
m = self.tools.session.m
|
130
|
+
if not hasattr(m, "container") or m.container is None:
|
131
|
+
m.create_container()
|
132
|
+
|
133
|
+
map_panel = widgets.VBox(
|
134
|
+
[widgets.HTML("<h3 style='margin:0 0 8px 0'>Map</h3>"), m.container],
|
135
|
+
layout=widgets.Layout(
|
136
|
+
flex="2 1 0%",
|
137
|
+
min_width="520px",
|
138
|
+
border="1px solid #ddd",
|
139
|
+
padding="8px",
|
140
|
+
height=f"{height}px",
|
141
|
+
overflow="hidden",
|
142
|
+
),
|
143
|
+
)
|
144
|
+
|
145
|
+
# ----- chat widgets -----
|
146
|
+
session_id = str(uuid.uuid4())[:8]
|
147
|
+
title = widgets.HTML(
|
148
|
+
f"<h3 style='margin:0'>Chatbot</h3>"
|
149
|
+
f"<p style='margin:4px 0 8px;color:#666'>Session: {session_id}</p>"
|
150
|
+
)
|
151
|
+
log = widgets.HTML(
|
152
|
+
value="<div style='color:#777'>No messages yet.</div>",
|
153
|
+
layout=widgets.Layout(
|
154
|
+
border="1px solid #ddd",
|
155
|
+
padding="8px",
|
156
|
+
height="520px",
|
157
|
+
overflow_y="auto",
|
158
|
+
),
|
159
|
+
)
|
160
|
+
inp = widgets.Textarea(
|
161
|
+
placeholder="Ask to add/remove/list layers, set basemap, save the map, etc.",
|
162
|
+
layout=widgets.Layout(width="99%", height="90px"),
|
163
|
+
)
|
164
|
+
btn_send = widgets.Button(
|
165
|
+
description="Send",
|
166
|
+
button_style="primary",
|
167
|
+
icon="paper-plane",
|
168
|
+
layout=widgets.Layout(width="120px"),
|
169
|
+
)
|
170
|
+
btn_stop = widgets.Button(
|
171
|
+
description="Stop", icon="stop", layout=widgets.Layout(width="120px")
|
172
|
+
)
|
173
|
+
btn_clear = widgets.Button(
|
174
|
+
description="Clear", icon="trash", layout=widgets.Layout(width="120px")
|
175
|
+
)
|
176
|
+
status = widgets.HTML("<span style='color:#666'>Ready.</span>")
|
177
|
+
|
178
|
+
examples = widgets.Dropdown(
|
179
|
+
options=[
|
180
|
+
("— Examples —", ""),
|
181
|
+
("Fly to", "Fly to Chicago"),
|
182
|
+
("Add basemap", "Add basemap OpenTopoMap"),
|
183
|
+
(
|
184
|
+
"Add COG layer",
|
185
|
+
"Add COG layer https://huggingface.co/datasets/giswqs/geospatial/resolve/main/naip_rgb_train.tif",
|
186
|
+
),
|
187
|
+
(
|
188
|
+
"Add GeoJSON",
|
189
|
+
"Add vector layer: https://github.com/opengeos/datasets/releases/download/us/us_states.geojson",
|
190
|
+
),
|
191
|
+
("Remove layer", "Remove layer OpenTopoMap"),
|
192
|
+
("Save map", "Save the map as demo.html and return the path"),
|
193
|
+
],
|
194
|
+
value="",
|
195
|
+
layout=widgets.Layout(width="auto"),
|
196
|
+
)
|
197
|
+
|
198
|
+
# --- state kept on self so it persists ---
|
199
|
+
self._ui = SimpleNamespace(
|
200
|
+
messages=[],
|
201
|
+
map_panel=map_panel,
|
202
|
+
title=title,
|
203
|
+
log=log,
|
204
|
+
inp=inp,
|
205
|
+
btn_send=btn_send,
|
206
|
+
btn_stop=btn_stop,
|
207
|
+
btn_clear=btn_clear,
|
208
|
+
status=status,
|
209
|
+
examples=examples,
|
210
|
+
)
|
211
|
+
self._pending = {"fut": None}
|
212
|
+
self._executor = ThreadPoolExecutor(max_workers=1)
|
213
|
+
|
214
|
+
def _esc(s: str) -> str:
|
215
|
+
"""Escape HTML characters in a string.
|
216
|
+
|
217
|
+
Args:
|
218
|
+
s: Input string to escape.
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
HTML-escaped string.
|
222
|
+
"""
|
223
|
+
return (
|
224
|
+
s.replace("&", "&")
|
225
|
+
.replace("<", "<")
|
226
|
+
.replace(">", ">")
|
227
|
+
.replace("\n", "<br/>")
|
228
|
+
)
|
229
|
+
|
230
|
+
def _append(role: str, msg: str) -> None:
|
231
|
+
"""Append a message to the chat log.
|
232
|
+
|
233
|
+
Args:
|
234
|
+
role: Role of the message sender ("user" or "assistant").
|
235
|
+
msg: Message content.
|
236
|
+
"""
|
237
|
+
self._ui.messages.append((role, msg))
|
238
|
+
parts = []
|
239
|
+
for r, mm in self._ui.messages:
|
240
|
+
if r == "user":
|
241
|
+
parts.append(
|
242
|
+
f"<div style='margin:6px 0;padding:6px 8px;border-radius:8px;background:#eef;'><b>You</b>: {_esc(mm)}</div>"
|
243
|
+
)
|
244
|
+
else:
|
245
|
+
parts.append(
|
246
|
+
f"<div style='margin:6px 0;padding:6px 8px;border-radius:8px;background:#f7f7f7;'><b>Agent</b>: {_esc(mm)}</div>"
|
247
|
+
)
|
248
|
+
self._ui.log.value = (
|
249
|
+
"<div>"
|
250
|
+
+ (
|
251
|
+
"".join(parts)
|
252
|
+
if parts
|
253
|
+
else "<div style='color:#777'>No messages yet.</div>"
|
254
|
+
)
|
255
|
+
+ "</div>"
|
256
|
+
)
|
257
|
+
|
258
|
+
def _lock(lock: bool) -> None:
|
259
|
+
"""Lock or unlock UI controls.
|
260
|
+
|
261
|
+
Args:
|
262
|
+
lock: True to lock controls, False to unlock.
|
263
|
+
"""
|
264
|
+
self._ui.btn_send.disabled = lock
|
265
|
+
self._ui.btn_stop.disabled = not lock
|
266
|
+
self._ui.btn_clear.disabled = lock
|
267
|
+
self._ui.inp.disabled = lock
|
268
|
+
self._ui.examples.disabled = lock
|
269
|
+
|
270
|
+
def _on_send(_: Any = None) -> None:
|
271
|
+
"""Handle send button click or Enter key press."""
|
272
|
+
text = self._ui.inp.value.strip()
|
273
|
+
if not text:
|
274
|
+
return
|
275
|
+
_append("user", text)
|
276
|
+
_lock(True)
|
277
|
+
self._ui.status.value = "<span style='color:#0a7'>Running…</span>"
|
278
|
+
try:
|
279
|
+
out = self.ask(text) # fresh Agent/model per call, silent
|
280
|
+
_append("assistant", out)
|
281
|
+
self._ui.status.value = "<span style='color:#0a7'>Done.</span>"
|
282
|
+
except Exception as e:
|
283
|
+
_append("assistant", f"[error] {type(e).__name__}: {e}")
|
284
|
+
self._ui.status.value = (
|
285
|
+
"<span style='color:#c00'>Finished with an issue.</span>"
|
286
|
+
)
|
287
|
+
finally:
|
288
|
+
self._ui.inp.value = ""
|
289
|
+
_lock(False)
|
290
|
+
|
291
|
+
def _on_stop(_: Any = None) -> None:
|
292
|
+
"""Handle stop button click."""
|
293
|
+
fut = self._pending.get("fut")
|
294
|
+
if fut and not fut.done():
|
295
|
+
self._pending["fut"] = None
|
296
|
+
self._ui.status.value = "<span style='color:#c00'>Stop requested. If it finishes, result will be ignored.</span>"
|
297
|
+
_lock(False)
|
298
|
+
|
299
|
+
def _on_clear(_: Any = None) -> None:
|
300
|
+
"""Handle clear button click."""
|
301
|
+
self._ui.messages.clear()
|
302
|
+
self._ui.log.value = "<div style='color:#777'>No messages yet.</div>"
|
303
|
+
self._ui.status.value = "<span style='color:#666'>Cleared.</span>"
|
304
|
+
|
305
|
+
def _on_example_change(change: dict[str, Any]) -> None:
|
306
|
+
"""Handle example dropdown selection change.
|
307
|
+
|
308
|
+
Args:
|
309
|
+
change: Change event dictionary from the dropdown widget.
|
310
|
+
"""
|
311
|
+
if change["name"] == "value" and change["new"]:
|
312
|
+
self._ui.inp.value = change["new"]
|
313
|
+
self._ui.examples.value = ""
|
314
|
+
self._ui.inp.send({"method": "focus"})
|
315
|
+
|
316
|
+
# keep handler refs
|
317
|
+
self._handlers = SimpleNamespace(
|
318
|
+
on_send=_on_send,
|
319
|
+
on_stop=_on_stop,
|
320
|
+
on_clear=_on_clear,
|
321
|
+
on_example_change=_on_example_change,
|
322
|
+
)
|
323
|
+
|
324
|
+
# wire events
|
325
|
+
self._ui.btn_send.on_click(self._handlers.on_send)
|
326
|
+
self._ui.btn_stop.on_click(self._handlers.on_stop)
|
327
|
+
self._ui.btn_clear.on_click(self._handlers.on_clear)
|
328
|
+
self._ui.examples.observe(self._handlers.on_example_change, names="value")
|
329
|
+
|
330
|
+
# Ctrl+Enter on textarea (keyup only; do not block defaults)
|
331
|
+
self._keyev = Event(
|
332
|
+
source=self._ui.inp, watched_events=["keyup"], prevent_default_action=False
|
333
|
+
)
|
334
|
+
|
335
|
+
def _on_key(e: dict[str, Any]) -> None:
|
336
|
+
"""Handle keyboard events on the input textarea.
|
337
|
+
|
338
|
+
Args:
|
339
|
+
e: Keyboard event dictionary.
|
340
|
+
"""
|
341
|
+
if (
|
342
|
+
e.get("type") == "keyup"
|
343
|
+
and e.get("key") == "Enter"
|
344
|
+
and e.get("ctrlKey")
|
345
|
+
):
|
346
|
+
if self._ui.inp.value.endswith("\n"):
|
347
|
+
self._ui.inp.value = self._ui.inp.value[:-1]
|
348
|
+
self._handlers.on_send()
|
349
|
+
|
350
|
+
# store callback too
|
351
|
+
self._on_key_cb: Callable[[dict[str, Any]], None] = _on_key
|
352
|
+
self._keyev.on_dom_event(self._on_key_cb)
|
353
|
+
|
354
|
+
buttons = widgets.HBox(
|
355
|
+
[
|
356
|
+
self._ui.btn_send,
|
357
|
+
self._ui.btn_stop,
|
358
|
+
self._ui.btn_clear,
|
359
|
+
widgets.Box(
|
360
|
+
[self._ui.examples], layout=widgets.Layout(margin="0 0 0 auto")
|
361
|
+
),
|
362
|
+
]
|
363
|
+
)
|
364
|
+
right = widgets.VBox(
|
365
|
+
[
|
366
|
+
self._ui.title if hasattr(self._ui, "title") else title,
|
367
|
+
self._ui.log,
|
368
|
+
self._ui.inp,
|
369
|
+
buttons,
|
370
|
+
self._ui.status,
|
371
|
+
],
|
372
|
+
layout=widgets.Layout(flex="1 1 0%", min_width="360px"),
|
373
|
+
)
|
374
|
+
root = widgets.HBox(
|
375
|
+
[map_panel, right], layout=widgets.Layout(width="100%", gap="8px")
|
376
|
+
)
|
377
|
+
display(root)
|