geoai-py 0.11.1__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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  __author__ = """Qiusheng Wu"""
4
4
  __email__ = "giswqs@gmail.com"
5
- __version__ = "0.11.1"
5
+ __version__ = "0.12.0"
6
6
 
7
7
 
8
8
  import os
@@ -0,0 +1,2 @@
1
+ from .geo_agents import GeoAgent
2
+ from .map_tools import MapTools
@@ -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("&", "&amp;")
225
+ .replace("<", "&lt;")
226
+ .replace(">", "&gt;")
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)