geomind-ai 1.1.1__tar.gz → 1.2.0__tar.gz
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.
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/PKG-INFO +1 -1
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind/__init__.py +1 -1
- geomind_ai-1.2.0/geomind/agent.py +218 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind/config.py +3 -3
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind/tools/processing.py +69 -32
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind/tools/stac_search.py +57 -17
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind_ai.egg-info/PKG-INFO +1 -1
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/pyproject.toml +1 -1
- geomind_ai-1.1.1/geomind/agent.py +0 -466
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/LICENSE +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/MANIFEST.in +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/README.md +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind/cli.py +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind/tools/__init__.py +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind/tools/geocoding.py +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind_ai.egg-info/SOURCES.txt +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind_ai.egg-info/dependency_links.txt +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind_ai.egg-info/entry_points.txt +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind_ai.egg-info/requires.txt +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/geomind_ai.egg-info/top_level.txt +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/requirements.txt +0 -0
- {geomind_ai-1.1.1 → geomind_ai-1.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GeoMind Agent - Simplified AI agent for satellite imagery analysis.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from openai import OpenAI
|
|
9
|
+
|
|
10
|
+
from .config import OPENROUTER_API_KEY, OPENROUTER_API_URL, OPENROUTER_MODEL
|
|
11
|
+
from .tools import (
|
|
12
|
+
list_recent_imagery,
|
|
13
|
+
create_rgb_composite,
|
|
14
|
+
calculate_ndvi,
|
|
15
|
+
search_imagery,
|
|
16
|
+
get_bbox_from_location,
|
|
17
|
+
geocode_location,
|
|
18
|
+
get_item_details,
|
|
19
|
+
get_band_statistics,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Tool function mapping
|
|
23
|
+
TOOL_FUNCTIONS = {
|
|
24
|
+
"list_recent_imagery": list_recent_imagery,
|
|
25
|
+
"create_rgb_composite": create_rgb_composite,
|
|
26
|
+
"calculate_ndvi": calculate_ndvi,
|
|
27
|
+
"search_imagery": search_imagery,
|
|
28
|
+
"get_bbox_from_location": get_bbox_from_location,
|
|
29
|
+
"geocode_location": geocode_location,
|
|
30
|
+
"get_item_details": get_item_details,
|
|
31
|
+
"get_band_statistics": get_band_statistics,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Simplified tool definitions
|
|
35
|
+
TOOLS = [
|
|
36
|
+
{
|
|
37
|
+
"type": "function",
|
|
38
|
+
"function": {
|
|
39
|
+
"name": "list_recent_imagery",
|
|
40
|
+
"description": "Find recent Sentinel-2 imagery for a location",
|
|
41
|
+
"parameters": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"location_name": {"type": "string", "description": "Location name"},
|
|
45
|
+
"days": {"type": "integer", "description": "Days to look back (default: 14)"},
|
|
46
|
+
"max_cloud_cover": {"type": "number", "description": "Max cloud cover %"},
|
|
47
|
+
"max_items": {"type": "integer", "description": "Max results"},
|
|
48
|
+
},
|
|
49
|
+
"required": [],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "function",
|
|
55
|
+
"function": {
|
|
56
|
+
"name": "create_rgb_composite",
|
|
57
|
+
"description": "Create RGB composite from Sentinel-2 data",
|
|
58
|
+
"parameters": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {
|
|
61
|
+
"zarr_url": {"type": "string", "description": "SR_10m Zarr URL"},
|
|
62
|
+
"location_name": {"type": "string", "description": "Location for title"},
|
|
63
|
+
"subset_size": {"type": "integer", "description": "Image size (default: 1000)"},
|
|
64
|
+
},
|
|
65
|
+
"required": ["zarr_url"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "function",
|
|
71
|
+
"function": {
|
|
72
|
+
"name": "calculate_ndvi",
|
|
73
|
+
"description": "Calculate NDVI vegetation index",
|
|
74
|
+
"parameters": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"zarr_url": {"type": "string", "description": "SR_10m Zarr URL"},
|
|
78
|
+
"location_name": {"type": "string", "description": "Location for title"},
|
|
79
|
+
"subset_size": {"type": "integer", "description": "Image size (default: 1000)"},
|
|
80
|
+
},
|
|
81
|
+
"required": ["zarr_url"],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"type": "function",
|
|
87
|
+
"function": {
|
|
88
|
+
"name": "search_imagery",
|
|
89
|
+
"description": "Search Sentinel-2 imagery by parameters",
|
|
90
|
+
"parameters": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"bbox": {"type": "array", "items": {"type": "number"}, "description": "Bounding box"},
|
|
94
|
+
"start_date": {"type": "string", "description": "Start date YYYY-MM-DD"},
|
|
95
|
+
"end_date": {"type": "string", "description": "End date YYYY-MM-DD"},
|
|
96
|
+
"max_cloud_cover": {"type": "number", "description": "Max cloud cover %"},
|
|
97
|
+
"max_items": {"type": "integer", "description": "Max results"},
|
|
98
|
+
},
|
|
99
|
+
"required": [],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"type": "function",
|
|
105
|
+
"function": {
|
|
106
|
+
"name": "get_bbox_from_location",
|
|
107
|
+
"description": "Get bounding box for a location",
|
|
108
|
+
"parameters": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"place_name": {"type": "string", "description": "Location name"},
|
|
112
|
+
"buffer_km": {"type": "number", "description": "Buffer distance in km"},
|
|
113
|
+
},
|
|
114
|
+
"required": ["place_name"],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class GeoMindAgent:
|
|
122
|
+
"""Simplified GeoMind agent for satellite imagery analysis."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, model: Optional[str] = None, api_key: Optional[str] = None):
|
|
125
|
+
self.api_key = api_key or OPENROUTER_API_KEY
|
|
126
|
+
self.model_name = model or OPENROUTER_MODEL
|
|
127
|
+
|
|
128
|
+
if not self.api_key:
|
|
129
|
+
raise ValueError("OpenRouter API key required")
|
|
130
|
+
|
|
131
|
+
print(f"GeoMind Agent initialized with {self.model_name}")
|
|
132
|
+
self.client = OpenAI(base_url=OPENROUTER_API_URL, api_key=self.api_key)
|
|
133
|
+
self.history = []
|
|
134
|
+
|
|
135
|
+
def _get_system_prompt(self) -> str:
|
|
136
|
+
"""Short, focused system prompt."""
|
|
137
|
+
return f"""You are GeoMind, an AI assistant for satellite imagery analysis using Sentinel-2 data.
|
|
138
|
+
|
|
139
|
+
Current date: {datetime.now().strftime('%Y-%m-%d')}
|
|
140
|
+
|
|
141
|
+
KEY RULES:
|
|
142
|
+
1. Use list_recent_imagery to find satellite data for locations
|
|
143
|
+
2. Extract Zarr URLs from results: item['zarr_assets']['SR_10m']['href']
|
|
144
|
+
3. Always include location_name parameter when creating images
|
|
145
|
+
4. Use actual function response data in your answers (don't make up paths/stats)
|
|
146
|
+
5. Let functions use default output paths (saves to outputs/ folder as .png)
|
|
147
|
+
|
|
148
|
+
Be helpful and accurate - always use real data from function responses."""
|
|
149
|
+
|
|
150
|
+
def chat(self, message: str, verbose: bool = True) -> str:
|
|
151
|
+
"""Send message to agent and get response."""
|
|
152
|
+
if verbose:
|
|
153
|
+
print(f"\nUser: {message}")
|
|
154
|
+
print("Processing...")
|
|
155
|
+
|
|
156
|
+
self.history.append({"role": "user", "content": message})
|
|
157
|
+
messages = [{"role": "system", "content": self._get_system_prompt()}] + self.history
|
|
158
|
+
|
|
159
|
+
for _ in range(10): # Max 10 iterations
|
|
160
|
+
response = self.client.chat.completions.create(
|
|
161
|
+
model=self.model_name,
|
|
162
|
+
messages=messages,
|
|
163
|
+
tools=TOOLS,
|
|
164
|
+
tool_choice="auto",
|
|
165
|
+
max_tokens=4096,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
choice = response.choices[0]
|
|
169
|
+
assistant_message = choice.message
|
|
170
|
+
|
|
171
|
+
tool_calls = assistant_message.tool_calls
|
|
172
|
+
if tool_calls:
|
|
173
|
+
# Add assistant message with tool calls
|
|
174
|
+
messages.append({
|
|
175
|
+
"role": "assistant",
|
|
176
|
+
"content": assistant_message.content or "",
|
|
177
|
+
"tool_calls": [
|
|
178
|
+
{
|
|
179
|
+
"id": tc.id,
|
|
180
|
+
"type": "function",
|
|
181
|
+
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
|
182
|
+
}
|
|
183
|
+
for tc in tool_calls
|
|
184
|
+
],
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
# Execute tool calls
|
|
188
|
+
for tool_call in tool_calls:
|
|
189
|
+
func_name = tool_call.function.name
|
|
190
|
+
func_args = json.loads(tool_call.function.arguments)
|
|
191
|
+
|
|
192
|
+
print(f" Executing: {func_name}({func_args})")
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
result = TOOL_FUNCTIONS[func_name](**func_args)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
result = {"error": str(e)}
|
|
198
|
+
|
|
199
|
+
messages.append({
|
|
200
|
+
"role": "tool",
|
|
201
|
+
"tool_call_id": tool_call.id,
|
|
202
|
+
"content": json.dumps(result, default=str),
|
|
203
|
+
})
|
|
204
|
+
else:
|
|
205
|
+
# Final response
|
|
206
|
+
final_text = assistant_message.content or ""
|
|
207
|
+
self.history.append({"role": "assistant", "content": final_text})
|
|
208
|
+
|
|
209
|
+
if verbose:
|
|
210
|
+
print(f"\nGeoMind: {final_text}")
|
|
211
|
+
|
|
212
|
+
return final_text
|
|
213
|
+
|
|
214
|
+
return "Max iterations reached"
|
|
215
|
+
|
|
216
|
+
def reset(self):
|
|
217
|
+
"""Reset chat history."""
|
|
218
|
+
self.history = []
|
|
@@ -37,9 +37,9 @@ REFLECTANCE_OFFSET = -0.1
|
|
|
37
37
|
RGB_BANDS = {"red": "b04", "green": "b03", "blue": "b02"}
|
|
38
38
|
|
|
39
39
|
# Default search parameters
|
|
40
|
-
DEFAULT_MAX_CLOUD_COVER =
|
|
41
|
-
DEFAULT_BUFFER_KM =
|
|
42
|
-
DEFAULT_MAX_ITEMS =
|
|
40
|
+
DEFAULT_MAX_CLOUD_COVER = 50 # percent (increased for better results)
|
|
41
|
+
DEFAULT_BUFFER_KM = 15 # km buffer around point for bbox (increased coverage)
|
|
42
|
+
DEFAULT_MAX_ITEMS = 20 # increased to find more options
|
|
43
43
|
|
|
44
44
|
# Output directory for saved images
|
|
45
45
|
OUTPUT_DIR = Path("outputs")
|
|
@@ -99,6 +99,7 @@ def create_rgb_composite(
|
|
|
99
99
|
zarr_url: str,
|
|
100
100
|
output_path: Optional[str] = None,
|
|
101
101
|
subset_size: Optional[int] = 1000,
|
|
102
|
+
location_name: Optional[str] = None,
|
|
102
103
|
) -> dict:
|
|
103
104
|
"""
|
|
104
105
|
Create an RGB composite image from Sentinel-2 10m bands.
|
|
@@ -109,6 +110,7 @@ def create_rgb_composite(
|
|
|
109
110
|
zarr_url: URL to the SR_10m Zarr asset or individual band asset URL
|
|
110
111
|
output_path: Optional path to save the image
|
|
111
112
|
subset_size: Size to subset the image (for faster processing)
|
|
113
|
+
location_name: Optional location name to include in the title
|
|
112
114
|
|
|
113
115
|
Returns:
|
|
114
116
|
Dictionary with path to saved image and metadata
|
|
@@ -116,38 +118,49 @@ def create_rgb_composite(
|
|
|
116
118
|
try:
|
|
117
119
|
import xarray as xr
|
|
118
120
|
import zarr
|
|
121
|
+
import re
|
|
119
122
|
|
|
120
123
|
# Determine if this is a band-specific URL or base SR_10m URL
|
|
121
|
-
# Band-specific URLs end with /
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
# Band-specific URLs end with /b01, /b02, etc. (Sentinel-2 band pattern)
|
|
125
|
+
url_parts = zarr_url.rstrip('/').split('/')
|
|
126
|
+
last_part = url_parts[-1]
|
|
127
|
+
is_band_url = re.match(r'^b(0[1-9]|1[0-2]|8a)(_\d+m)?$', last_part.lower()) is not None
|
|
124
128
|
|
|
125
129
|
if is_band_url:
|
|
126
130
|
# Individual band URL provided - need to construct URLs for each band
|
|
127
131
|
base_url = '/'.join(zarr_url.rstrip('/').split('/')[:-1])
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
133
|
+
green_zarr = zarr.open(f"{base_url}/b03", mode="r")
|
|
134
|
+
blue_zarr = zarr.open(f"{base_url}/b02", mode="r")
|
|
131
135
|
else:
|
|
132
136
|
# Base SR_10m URL - bands are subdirectories
|
|
133
137
|
base_url = zarr_url.rstrip('/')
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
138
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
139
|
+
green_zarr = zarr.open(f"{base_url}/b03", mode="r")
|
|
140
|
+
blue_zarr = zarr.open(f"{base_url}/b02", mode="r")
|
|
141
|
+
|
|
142
|
+
# Determine subset region before loading data
|
|
143
|
+
full_shape = red_zarr.shape
|
|
144
|
+
if subset_size and full_shape[0] > subset_size:
|
|
145
|
+
# Calculate center subset
|
|
146
|
+
h, w = full_shape
|
|
147
|
+
start_h = (h - subset_size) // 2
|
|
148
|
+
start_w = (w - subset_size) // 2
|
|
149
|
+
|
|
150
|
+
# Load only the subset (memory efficient)
|
|
151
|
+
red = np.array(red_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
152
|
+
green = np.array(green_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
153
|
+
blue = np.array(blue_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
154
|
+
else:
|
|
155
|
+
# Load full arrays (be careful with large datasets)
|
|
156
|
+
red = np.array(red_zarr)
|
|
157
|
+
green = np.array(green_zarr)
|
|
158
|
+
blue = np.array(blue_zarr)
|
|
137
159
|
|
|
138
160
|
# Subset if requested (for faster processing)
|
|
139
161
|
if subset_size and red.shape[0] > subset_size:
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
start_h = (h - subset_size) // 2
|
|
143
|
-
start_w = (w - subset_size) // 2
|
|
144
|
-
red = red[start_h : start_h + subset_size, start_w : start_w + subset_size]
|
|
145
|
-
green = green[
|
|
146
|
-
start_h : start_h + subset_size, start_w : start_w + subset_size
|
|
147
|
-
]
|
|
148
|
-
blue = blue[
|
|
149
|
-
start_h : start_h + subset_size, start_w : start_w + subset_size
|
|
150
|
-
]
|
|
162
|
+
# Already subsetted during loading - this section can be removed
|
|
163
|
+
pass
|
|
151
164
|
|
|
152
165
|
# Apply scale and offset
|
|
153
166
|
red = _apply_scale_offset(red)
|
|
@@ -171,7 +184,12 @@ def create_rgb_composite(
|
|
|
171
184
|
# Create figure
|
|
172
185
|
fig, ax = plt.subplots(figsize=(10, 10))
|
|
173
186
|
ax.imshow(rgb)
|
|
174
|
-
|
|
187
|
+
|
|
188
|
+
# Create dynamic title with location
|
|
189
|
+
title = "Sentinel-2 RGB Composite (B4/B3/B2)"
|
|
190
|
+
if location_name:
|
|
191
|
+
title += f" - {location_name}"
|
|
192
|
+
ax.set_title(title)
|
|
175
193
|
ax.axis("off")
|
|
176
194
|
|
|
177
195
|
# Save
|
|
@@ -197,6 +215,7 @@ def calculate_ndvi(
|
|
|
197
215
|
zarr_url: str,
|
|
198
216
|
output_path: Optional[str] = None,
|
|
199
217
|
subset_size: Optional[int] = 1000,
|
|
218
|
+
location_name: Optional[str] = None,
|
|
200
219
|
) -> dict:
|
|
201
220
|
"""
|
|
202
221
|
Calculate NDVI (Normalized Difference Vegetation Index) from Sentinel-2 data.
|
|
@@ -208,6 +227,7 @@ def calculate_ndvi(
|
|
|
208
227
|
zarr_url: URL to the SR_10m Zarr asset or individual band asset URL
|
|
209
228
|
output_path: Optional path to save the NDVI image
|
|
210
229
|
subset_size: Size to subset the image
|
|
230
|
+
location_name: Optional location name to include in the title
|
|
211
231
|
|
|
212
232
|
Returns:
|
|
213
233
|
Dictionary with NDVI statistics and output path
|
|
@@ -215,28 +235,40 @@ def calculate_ndvi(
|
|
|
215
235
|
try:
|
|
216
236
|
import zarr
|
|
217
237
|
from matplotlib.colors import LinearSegmentedColormap
|
|
238
|
+
import re
|
|
218
239
|
|
|
219
240
|
# Determine if this is a band-specific URL or base SR_10m URL
|
|
220
|
-
|
|
241
|
+
url_parts = zarr_url.rstrip('/').split('/')
|
|
242
|
+
last_part = url_parts[-1]
|
|
243
|
+
is_band_url = re.match(r'^b(0[1-9]|1[0-2]|8a)(_\d+m)?$', last_part.lower()) is not None
|
|
221
244
|
|
|
222
245
|
if is_band_url:
|
|
223
246
|
# Individual band URL provided
|
|
224
247
|
base_url = '/'.join(zarr_url.rstrip('/').split('/')[:-1])
|
|
225
|
-
|
|
226
|
-
|
|
248
|
+
nir_zarr = zarr.open(f"{base_url}/b08", mode="r")
|
|
249
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
227
250
|
else:
|
|
228
251
|
# Base SR_10m URL
|
|
229
252
|
base_url = zarr_url.rstrip('/')
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
nir_zarr = zarr.open(f"{base_url}/b08", mode="r")
|
|
254
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
232
255
|
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
256
|
+
# Determine subset region and load efficiently
|
|
257
|
+
full_shape = nir_zarr.shape
|
|
258
|
+
if subset_size and full_shape[0] > subset_size:
|
|
259
|
+
h, w = full_shape
|
|
236
260
|
start_h = (h - subset_size) // 2
|
|
237
261
|
start_w = (w - subset_size) // 2
|
|
238
|
-
nir =
|
|
239
|
-
red =
|
|
262
|
+
nir = np.array(nir_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
263
|
+
red = np.array(red_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
264
|
+
else:
|
|
265
|
+
nir = np.array(nir_zarr)
|
|
266
|
+
red = np.array(red_zarr)
|
|
267
|
+
|
|
268
|
+
# Subset if requested - already handled during loading
|
|
269
|
+
if subset_size and nir.shape[0] > subset_size:
|
|
270
|
+
# Already subsetted during loading
|
|
271
|
+
pass
|
|
240
272
|
|
|
241
273
|
# Apply scale and offset
|
|
242
274
|
nir = _apply_scale_offset(nir)
|
|
@@ -271,7 +303,12 @@ def calculate_ndvi(
|
|
|
271
303
|
# Create figure
|
|
272
304
|
fig, ax = plt.subplots(figsize=(10, 10))
|
|
273
305
|
im = ax.imshow(ndvi, cmap=ndvi_cmap, vmin=-1, vmax=1)
|
|
274
|
-
|
|
306
|
+
|
|
307
|
+
# Create dynamic title with location
|
|
308
|
+
title = "NDVI - Normalized Difference Vegetation Index"
|
|
309
|
+
if location_name:
|
|
310
|
+
title += f" - {location_name}"
|
|
311
|
+
ax.set_title(title)
|
|
275
312
|
ax.axis("off")
|
|
276
313
|
|
|
277
314
|
# Add colorbar
|
|
@@ -25,23 +25,32 @@ def _format_item(item) -> dict:
|
|
|
25
25
|
"""Format a STAC item into a simplified dictionary."""
|
|
26
26
|
props = item.properties
|
|
27
27
|
|
|
28
|
-
# Extract
|
|
28
|
+
# Extract ALL assets, with special handling for Zarr assets
|
|
29
29
|
assets = {}
|
|
30
|
+
zarr_assets = {}
|
|
31
|
+
other_assets = {}
|
|
32
|
+
|
|
30
33
|
for key, asset in item.assets.items():
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
asset_info = {
|
|
35
|
+
"title": asset.title,
|
|
36
|
+
"href": asset.href,
|
|
37
|
+
"type": asset.media_type,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Add band information for individual band assets
|
|
41
|
+
if "_" in key and key.split("_")[0] in ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12"]:
|
|
42
|
+
band_name = key.split("_")[0].lower()
|
|
43
|
+
asset_info["band"] = band_name
|
|
44
|
+
asset_info["resolution"] = key.split("_")[1] if "_" in key else "unknown"
|
|
45
|
+
|
|
46
|
+
# Categorize assets by type
|
|
47
|
+
if asset.media_type and "zarr" in asset.media_type.lower():
|
|
48
|
+
zarr_assets[key] = asset_info
|
|
49
|
+
else:
|
|
50
|
+
other_assets[key] = asset_info
|
|
51
|
+
|
|
52
|
+
# Include all assets in main assets dict
|
|
53
|
+
assets[key] = asset_info
|
|
45
54
|
|
|
46
55
|
return {
|
|
47
56
|
"id": item.id,
|
|
@@ -51,7 +60,14 @@ def _format_item(item) -> dict:
|
|
|
51
60
|
"bbox": item.bbox,
|
|
52
61
|
"geometry": item.geometry,
|
|
53
62
|
"assets": assets,
|
|
63
|
+
"zarr_assets": zarr_assets, # Separate zarr assets for easy access
|
|
64
|
+
"other_assets": other_assets, # Non-zarr assets
|
|
54
65
|
"stac_url": f"{STAC_API_URL}/collections/{STAC_COLLECTION}/items/{item.id}",
|
|
66
|
+
"asset_summary": {
|
|
67
|
+
"total_assets": len(assets),
|
|
68
|
+
"zarr_assets": len(zarr_assets),
|
|
69
|
+
"other_assets": len(other_assets),
|
|
70
|
+
}
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
|
|
@@ -139,6 +155,10 @@ def search_imagery(
|
|
|
139
155
|
"datetime": datetime_str,
|
|
140
156
|
"max_cloud_cover": max_cloud_cover,
|
|
141
157
|
},
|
|
158
|
+
"quality_info": {
|
|
159
|
+
"avg_cloud_cover": sum(item.properties.get("eo:cloud_cover", 100) for item in items) / len(items) if items else None,
|
|
160
|
+
"best_cloud_cover": min(item.properties.get("eo:cloud_cover", 100) for item in items) if items else None,
|
|
161
|
+
} if items else {},
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
except Exception as e:
|
|
@@ -186,7 +206,7 @@ def get_item_details(item_id: str) -> dict:
|
|
|
186
206
|
|
|
187
207
|
def list_recent_imagery(
|
|
188
208
|
location_name: Optional[str] = None,
|
|
189
|
-
days: int = 7
|
|
209
|
+
days: int = 14, # Changed from 7 to 14 days default
|
|
190
210
|
max_cloud_cover: Optional[float] = None,
|
|
191
211
|
max_items: Optional[int] = None,
|
|
192
212
|
) -> dict:
|
|
@@ -194,10 +214,11 @@ def list_recent_imagery(
|
|
|
194
214
|
List recent Sentinel-2 imagery, optionally for a specific location.
|
|
195
215
|
|
|
196
216
|
This is a convenience function that combines geocoding and search.
|
|
217
|
+
If no good quality results found, automatically extends search period.
|
|
197
218
|
|
|
198
219
|
Args:
|
|
199
220
|
location_name: Optional place name to search around
|
|
200
|
-
days: Number of days to look back (default:
|
|
221
|
+
days: Number of days to look back (default: 14)
|
|
201
222
|
max_cloud_cover: Maximum cloud cover percentage
|
|
202
223
|
max_items: Maximum items to return
|
|
203
224
|
|
|
@@ -238,6 +259,25 @@ def list_recent_imagery(
|
|
|
238
259
|
max_items=max_items,
|
|
239
260
|
)
|
|
240
261
|
|
|
262
|
+
# If no results found and we used a restrictive cloud cover, try extending time and relaxing cloud cover
|
|
263
|
+
if result.get("success") and result.get("filtered_count", 0) == 0 and days < 30:
|
|
264
|
+
# Try 30 days with more permissive cloud cover
|
|
265
|
+
extended_start = end_date - timedelta(days=30)
|
|
266
|
+
relaxed_cloud = min((max_cloud_cover or DEFAULT_MAX_CLOUD_COVER) + 30, 80)
|
|
267
|
+
|
|
268
|
+
extended_result = search_imagery(
|
|
269
|
+
bbox=bbox,
|
|
270
|
+
start_date=extended_start.strftime("%Y-%m-%d"),
|
|
271
|
+
end_date=end_date.strftime("%Y-%m-%d"),
|
|
272
|
+
max_cloud_cover=relaxed_cloud,
|
|
273
|
+
max_items=max_items,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if extended_result.get("success") and extended_result.get("filtered_count", 0) > 0:
|
|
277
|
+
result = extended_result
|
|
278
|
+
result["extended_search"] = True
|
|
279
|
+
result["note"] = f"Extended search to 30 days with {relaxed_cloud}% cloud cover for better results"
|
|
280
|
+
|
|
241
281
|
if location_info:
|
|
242
282
|
result["location"] = location_info
|
|
243
283
|
|
|
@@ -1,466 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
GeoMind Agent - Main agent class powered by OpenRouter.
|
|
3
|
-
|
|
4
|
-
This module implements the AI agent that can understand natural language
|
|
5
|
-
queries about satellite imagery and execute the appropriate tools.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
import re
|
|
10
|
-
from typing import Optional, Callable, Any
|
|
11
|
-
from datetime import datetime
|
|
12
|
-
|
|
13
|
-
from openai import OpenAI
|
|
14
|
-
|
|
15
|
-
from .config import (
|
|
16
|
-
OPENROUTER_API_KEY,
|
|
17
|
-
OPENROUTER_API_URL,
|
|
18
|
-
OPENROUTER_MODEL,
|
|
19
|
-
)
|
|
20
|
-
from .tools import (
|
|
21
|
-
geocode_location,
|
|
22
|
-
get_bbox_from_location,
|
|
23
|
-
search_imagery,
|
|
24
|
-
get_item_details,
|
|
25
|
-
list_recent_imagery,
|
|
26
|
-
create_rgb_composite,
|
|
27
|
-
calculate_ndvi,
|
|
28
|
-
get_band_statistics,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# Map tool names to functions
|
|
33
|
-
TOOL_FUNCTIONS = {
|
|
34
|
-
"geocode_location": geocode_location,
|
|
35
|
-
"get_bbox_from_location": get_bbox_from_location,
|
|
36
|
-
"search_imagery": search_imagery,
|
|
37
|
-
"list_recent_imagery": list_recent_imagery,
|
|
38
|
-
"get_item_details": get_item_details,
|
|
39
|
-
"create_rgb_composite": create_rgb_composite,
|
|
40
|
-
"calculate_ndvi": calculate_ndvi,
|
|
41
|
-
"get_band_statistics": get_band_statistics,
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
# Tool definitions for the LLM
|
|
45
|
-
TOOLS = [
|
|
46
|
-
{
|
|
47
|
-
"type": "function",
|
|
48
|
-
"function": {
|
|
49
|
-
"name": "geocode_location",
|
|
50
|
-
"description": "Convert a place name to geographic coordinates (latitude, longitude). Use this when you need to find coordinates for a location.",
|
|
51
|
-
"parameters": {
|
|
52
|
-
"type": "object",
|
|
53
|
-
"properties": {
|
|
54
|
-
"place_name": {
|
|
55
|
-
"type": "string",
|
|
56
|
-
"description": "The name of the place to geocode (e.g., 'New York City', 'Paris, France')",
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
"required": ["place_name"],
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
"type": "function",
|
|
65
|
-
"function": {
|
|
66
|
-
"name": "get_bbox_from_location",
|
|
67
|
-
"description": "Get a bounding box for a location, suitable for searching satellite imagery.",
|
|
68
|
-
"parameters": {
|
|
69
|
-
"type": "object",
|
|
70
|
-
"properties": {
|
|
71
|
-
"place_name": {
|
|
72
|
-
"type": "string",
|
|
73
|
-
"description": "The name of the place",
|
|
74
|
-
},
|
|
75
|
-
"buffer_km": {
|
|
76
|
-
"type": "number",
|
|
77
|
-
"description": "Buffer distance in kilometers (default: 10)",
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
"required": ["place_name"],
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"type": "function",
|
|
86
|
-
"function": {
|
|
87
|
-
"name": "search_imagery",
|
|
88
|
-
"description": "Search for Sentinel-2 satellite imagery in the EOPF catalog. Returns available scenes.",
|
|
89
|
-
"parameters": {
|
|
90
|
-
"type": "object",
|
|
91
|
-
"properties": {
|
|
92
|
-
"bbox": {
|
|
93
|
-
"type": "array",
|
|
94
|
-
"items": {"type": "number"},
|
|
95
|
-
"description": "Bounding box as [min_lon, min_lat, max_lon, max_lat]",
|
|
96
|
-
},
|
|
97
|
-
"start_date": {
|
|
98
|
-
"type": "string",
|
|
99
|
-
"description": "Start date in YYYY-MM-DD format",
|
|
100
|
-
},
|
|
101
|
-
"end_date": {
|
|
102
|
-
"type": "string",
|
|
103
|
-
"description": "End date in YYYY-MM-DD format",
|
|
104
|
-
},
|
|
105
|
-
"max_cloud_cover": {
|
|
106
|
-
"type": "number",
|
|
107
|
-
"description": "Maximum cloud cover percentage (0-100)",
|
|
108
|
-
},
|
|
109
|
-
"max_items": {
|
|
110
|
-
"type": "integer",
|
|
111
|
-
"description": "Maximum number of results",
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
"required": [],
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
{
|
|
119
|
-
"type": "function",
|
|
120
|
-
"function": {
|
|
121
|
-
"name": "list_recent_imagery",
|
|
122
|
-
"description": "List recent Sentinel-2 imagery for a location. Combines geocoding and search.",
|
|
123
|
-
"parameters": {
|
|
124
|
-
"type": "object",
|
|
125
|
-
"properties": {
|
|
126
|
-
"location_name": {
|
|
127
|
-
"type": "string",
|
|
128
|
-
"description": "Name of the location to search",
|
|
129
|
-
},
|
|
130
|
-
"days": {
|
|
131
|
-
"type": "integer",
|
|
132
|
-
"description": "Number of days to look back (default: 7)",
|
|
133
|
-
},
|
|
134
|
-
"max_cloud_cover": {
|
|
135
|
-
"type": "number",
|
|
136
|
-
"description": "Maximum cloud cover percentage",
|
|
137
|
-
},
|
|
138
|
-
"max_items": {
|
|
139
|
-
"type": "integer",
|
|
140
|
-
"description": "Maximum number of results",
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
"required": [],
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
"type": "function",
|
|
149
|
-
"function": {
|
|
150
|
-
"name": "get_item_details",
|
|
151
|
-
"description": "Get detailed information about a specific Sentinel-2 scene by its ID.",
|
|
152
|
-
"parameters": {
|
|
153
|
-
"type": "object",
|
|
154
|
-
"properties": {
|
|
155
|
-
"item_id": {"type": "string", "description": "The STAC item ID"}
|
|
156
|
-
},
|
|
157
|
-
"required": ["item_id"],
|
|
158
|
-
},
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
"type": "function",
|
|
163
|
-
"function": {
|
|
164
|
-
"name": "create_rgb_composite",
|
|
165
|
-
"description": "Create an RGB true-color composite image from Sentinel-2 data.",
|
|
166
|
-
"parameters": {
|
|
167
|
-
"type": "object",
|
|
168
|
-
"properties": {
|
|
169
|
-
"zarr_url": {
|
|
170
|
-
"type": "string",
|
|
171
|
-
"description": "URL to the SR_10m Zarr asset from a STAC item",
|
|
172
|
-
},
|
|
173
|
-
"output_path": {
|
|
174
|
-
"type": "string",
|
|
175
|
-
"description": "Optional path to save the output image",
|
|
176
|
-
},
|
|
177
|
-
"subset_size": {
|
|
178
|
-
"type": "integer",
|
|
179
|
-
"description": "Size to subset the image (default: 1000 pixels)",
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
"required": ["zarr_url"],
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
{
|
|
187
|
-
"type": "function",
|
|
188
|
-
"function": {
|
|
189
|
-
"name": "calculate_ndvi",
|
|
190
|
-
"description": "Calculate NDVI (vegetation index) from Sentinel-2 data.",
|
|
191
|
-
"parameters": {
|
|
192
|
-
"type": "object",
|
|
193
|
-
"properties": {
|
|
194
|
-
"zarr_url": {
|
|
195
|
-
"type": "string",
|
|
196
|
-
"description": "URL to the SR_10m Zarr asset",
|
|
197
|
-
},
|
|
198
|
-
"output_path": {
|
|
199
|
-
"type": "string",
|
|
200
|
-
"description": "Optional path to save the NDVI image",
|
|
201
|
-
},
|
|
202
|
-
"subset_size": {
|
|
203
|
-
"type": "integer",
|
|
204
|
-
"description": "Size to subset the image",
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
"required": ["zarr_url"],
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
"type": "function",
|
|
213
|
-
"function": {
|
|
214
|
-
"name": "get_band_statistics",
|
|
215
|
-
"description": "Get statistics (min, max, mean) for spectral bands.",
|
|
216
|
-
"parameters": {
|
|
217
|
-
"type": "object",
|
|
218
|
-
"properties": {
|
|
219
|
-
"zarr_url": {
|
|
220
|
-
"type": "string",
|
|
221
|
-
"description": "URL to the Zarr asset",
|
|
222
|
-
},
|
|
223
|
-
"bands": {
|
|
224
|
-
"type": "array",
|
|
225
|
-
"items": {"type": "string"},
|
|
226
|
-
"description": "List of band names to analyze",
|
|
227
|
-
},
|
|
228
|
-
},
|
|
229
|
-
"required": ["zarr_url"],
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
},
|
|
233
|
-
]
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
class GeoMindAgent:
|
|
237
|
-
"""
|
|
238
|
-
GeoMind - An AI agent for geospatial analysis with Sentinel-2 imagery.
|
|
239
|
-
|
|
240
|
-
Uses OpenRouter API for access to multiple AI models.
|
|
241
|
-
"""
|
|
242
|
-
|
|
243
|
-
def __init__(self, model: Optional[str] = None, api_key: Optional[str] = None):
|
|
244
|
-
"""
|
|
245
|
-
Initialize the GeoMind agent.
|
|
246
|
-
|
|
247
|
-
Args:
|
|
248
|
-
model: Model name (default: nvidia/nemotron-3-nano-30b-a3b:free)
|
|
249
|
-
api_key: OpenRouter API key (required).
|
|
250
|
-
"""
|
|
251
|
-
self.provider = "openrouter"
|
|
252
|
-
self.api_key = api_key or OPENROUTER_API_KEY
|
|
253
|
-
self.model_name = model or OPENROUTER_MODEL
|
|
254
|
-
self.base_url = OPENROUTER_API_URL
|
|
255
|
-
|
|
256
|
-
if not self.api_key:
|
|
257
|
-
raise ValueError(
|
|
258
|
-
"OpenRouter API key required.\n"
|
|
259
|
-
"Get your FREE API key at: https://openrouter.ai/settings/keys\n\n"
|
|
260
|
-
"Then provide it in one of these ways:\n"
|
|
261
|
-
"1. Run: geomind --api-key YOUR_KEY\n"
|
|
262
|
-
"2. Set environment variable: OPENROUTER_API_KEY=YOUR_KEY\n"
|
|
263
|
-
"3. Create .env file with: OPENROUTER_API_KEY=YOUR_KEY"
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
print(f"GeoMind Agent initialized with {self.model_name}")
|
|
267
|
-
|
|
268
|
-
# Create OpenAI-compatible client
|
|
269
|
-
self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)
|
|
270
|
-
|
|
271
|
-
# Chat history
|
|
272
|
-
self.history = []
|
|
273
|
-
|
|
274
|
-
# Add system message
|
|
275
|
-
self.system_prompt = self._get_system_prompt()
|
|
276
|
-
|
|
277
|
-
def _get_system_prompt(self) -> str:
|
|
278
|
-
"""Get the system prompt for the agent."""
|
|
279
|
-
return f"""You are GeoMind, an expert AI assistant specialized in geospatial analysis
|
|
280
|
-
and satellite imagery. You help users find, analyze, and visualize Sentinel-2 satellite data
|
|
281
|
-
from the EOPF (ESA Earth Observation Processing Framework) catalog.
|
|
282
|
-
|
|
283
|
-
Your capabilities include:
|
|
284
|
-
1. **Search**: Find Sentinel-2 L2A imagery by location, date, and cloud cover
|
|
285
|
-
2. **Geocoding**: Convert place names to coordinates for searching
|
|
286
|
-
3. **Visualization**: Create RGB composites and NDVI maps from imagery
|
|
287
|
-
4. **Analysis**: Calculate spectral indices and band statistics
|
|
288
|
-
|
|
289
|
-
Key information:
|
|
290
|
-
- Data source: EOPF STAC API (https://stac.core.eopf.eodc.eu)
|
|
291
|
-
- Satellite: Sentinel-2 (L2A surface reflectance products)
|
|
292
|
-
- Bands available: B01-B12 at 10m, 20m, or 60m resolution
|
|
293
|
-
- Current date: {datetime.now().strftime('%Y-%m-%d')}
|
|
294
|
-
|
|
295
|
-
IMPORTANT - Zarr URL usage:
|
|
296
|
-
- STAC search results include both SR_10m (base URL) and individual band assets (B02_10m, B03_10m, B04_10m, B08_10m)
|
|
297
|
-
- EITHER type of URL works for create_rgb_composite and calculate_ndvi:
|
|
298
|
-
* SR_10m URL: Points to .../measurements/reflectance/r10m (contains all bands as subdirectories)
|
|
299
|
-
* Individual band URLs: Point directly to specific bands like .../r10m/b02
|
|
300
|
-
- Prefer using SR_10m URL as it's simpler and works for all bands
|
|
301
|
-
- The processing functions automatically handle the correct path structure
|
|
302
|
-
|
|
303
|
-
When users ask for imagery:
|
|
304
|
-
1. First use get_bbox_from_location or list_recent_imagery to search
|
|
305
|
-
2. Present the results clearly with key metadata (ID, date, cloud cover)
|
|
306
|
-
3. Offer to create visualizations if data is found
|
|
307
|
-
4. For visualizations, use the SR_10m asset URL from search results
|
|
308
|
-
|
|
309
|
-
Always explain what you're doing and interpret results in a helpful way."""
|
|
310
|
-
|
|
311
|
-
def _call_llm(self, messages: list, tools: list) -> dict:
|
|
312
|
-
"""Call LLM via OpenAI-compatible client."""
|
|
313
|
-
response = self.client.chat.completions.create(
|
|
314
|
-
model=self.model_name,
|
|
315
|
-
messages=messages,
|
|
316
|
-
tools=tools,
|
|
317
|
-
tool_choice="auto",
|
|
318
|
-
max_tokens=4096,
|
|
319
|
-
)
|
|
320
|
-
return response.model_dump()
|
|
321
|
-
|
|
322
|
-
def _execute_function(self, name: str, args: dict) -> dict:
|
|
323
|
-
"""Execute a function call and return the result."""
|
|
324
|
-
print(f" Executing: {name}({args})")
|
|
325
|
-
|
|
326
|
-
if name not in TOOL_FUNCTIONS:
|
|
327
|
-
return {"error": f"Unknown function: {name}"}
|
|
328
|
-
|
|
329
|
-
try:
|
|
330
|
-
result = TOOL_FUNCTIONS[name](**args)
|
|
331
|
-
return result
|
|
332
|
-
except Exception as e:
|
|
333
|
-
return {"error": str(e)}
|
|
334
|
-
|
|
335
|
-
def chat(self, message: str, verbose: bool = True) -> str:
|
|
336
|
-
"""
|
|
337
|
-
Send a message to the agent and get a response.
|
|
338
|
-
"""
|
|
339
|
-
if verbose:
|
|
340
|
-
print(f"\nUser: {message}")
|
|
341
|
-
print("Processing...")
|
|
342
|
-
|
|
343
|
-
# Add user message to history
|
|
344
|
-
self.history.append({"role": "user", "content": message})
|
|
345
|
-
|
|
346
|
-
# Build messages with system prompt
|
|
347
|
-
messages = [{"role": "system", "content": self.system_prompt}] + self.history
|
|
348
|
-
|
|
349
|
-
max_iterations = 10
|
|
350
|
-
iteration = 0
|
|
351
|
-
|
|
352
|
-
while iteration < max_iterations:
|
|
353
|
-
iteration += 1
|
|
354
|
-
|
|
355
|
-
# Call the model (via proxy or direct)
|
|
356
|
-
response_data = self._call_llm(messages, TOOLS)
|
|
357
|
-
|
|
358
|
-
# Extract assistant message from response
|
|
359
|
-
choice = response_data["choices"][0]
|
|
360
|
-
assistant_message = choice["message"]
|
|
361
|
-
|
|
362
|
-
# Check if there are tool calls
|
|
363
|
-
tool_calls = assistant_message.get("tool_calls", [])
|
|
364
|
-
if tool_calls:
|
|
365
|
-
# Add assistant message with tool calls to messages
|
|
366
|
-
messages.append(
|
|
367
|
-
{
|
|
368
|
-
"role": "assistant",
|
|
369
|
-
"content": assistant_message.get("content") or "",
|
|
370
|
-
"tool_calls": [
|
|
371
|
-
{
|
|
372
|
-
"id": tc["id"],
|
|
373
|
-
"type": "function",
|
|
374
|
-
"function": {
|
|
375
|
-
"name": tc["function"]["name"],
|
|
376
|
-
"arguments": tc["function"]["arguments"],
|
|
377
|
-
},
|
|
378
|
-
}
|
|
379
|
-
for tc in tool_calls
|
|
380
|
-
],
|
|
381
|
-
}
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
# Execute each tool call
|
|
385
|
-
for tool_call in tool_calls:
|
|
386
|
-
func_name = tool_call["function"]["name"]
|
|
387
|
-
func_args = json.loads(tool_call["function"]["arguments"])
|
|
388
|
-
|
|
389
|
-
result = self._execute_function(func_name, func_args)
|
|
390
|
-
|
|
391
|
-
# Add tool result to messages
|
|
392
|
-
messages.append(
|
|
393
|
-
{
|
|
394
|
-
"role": "tool",
|
|
395
|
-
"tool_call_id": tool_call["id"],
|
|
396
|
-
"content": json.dumps(result, default=str),
|
|
397
|
-
}
|
|
398
|
-
)
|
|
399
|
-
else:
|
|
400
|
-
# No tool calls, we have a final response
|
|
401
|
-
final_text = assistant_message.get("content") or ""
|
|
402
|
-
|
|
403
|
-
# Add to history
|
|
404
|
-
self.history.append({"role": "assistant", "content": final_text})
|
|
405
|
-
|
|
406
|
-
if verbose:
|
|
407
|
-
print(f"\nGeoMind: {final_text}")
|
|
408
|
-
|
|
409
|
-
return final_text
|
|
410
|
-
|
|
411
|
-
return "Max iterations reached."
|
|
412
|
-
|
|
413
|
-
def reset(self):
|
|
414
|
-
"""Reset the chat session."""
|
|
415
|
-
self.history = []
|
|
416
|
-
print("Chat session reset")
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def main(model: Optional[str] = None):
|
|
420
|
-
"""Main entry point for CLI usage."""
|
|
421
|
-
import sys
|
|
422
|
-
|
|
423
|
-
print("=" * 60)
|
|
424
|
-
print("GeoMind - Geospatial AI Agent")
|
|
425
|
-
print("=" * 60)
|
|
426
|
-
print("Powered by OpenRouter | Sentinel-2 Imagery")
|
|
427
|
-
print("Type 'quit' or 'exit' to end the session")
|
|
428
|
-
print("Type 'reset' to start a new conversation")
|
|
429
|
-
print("=" * 60)
|
|
430
|
-
|
|
431
|
-
try:
|
|
432
|
-
agent = GeoMindAgent(model=model)
|
|
433
|
-
except ValueError as e:
|
|
434
|
-
print(f"\nError: {e}")
|
|
435
|
-
sys.exit(1)
|
|
436
|
-
except Exception as e:
|
|
437
|
-
print(f"\nError: {e}")
|
|
438
|
-
print("\nPlease check your API key and internet connection.")
|
|
439
|
-
sys.exit(1)
|
|
440
|
-
|
|
441
|
-
while True:
|
|
442
|
-
try:
|
|
443
|
-
user_input = input("\nYou: ").strip()
|
|
444
|
-
|
|
445
|
-
if not user_input:
|
|
446
|
-
continue
|
|
447
|
-
|
|
448
|
-
if user_input.lower() in ["quit", "exit", "q"]:
|
|
449
|
-
print("\nGoodbye!")
|
|
450
|
-
break
|
|
451
|
-
|
|
452
|
-
if user_input.lower() == "reset":
|
|
453
|
-
agent.reset()
|
|
454
|
-
continue
|
|
455
|
-
|
|
456
|
-
agent.chat(user_input)
|
|
457
|
-
|
|
458
|
-
except KeyboardInterrupt:
|
|
459
|
-
print("\n\nGoodbye!")
|
|
460
|
-
break
|
|
461
|
-
except Exception as e:
|
|
462
|
-
print(f"\nError: {e}")
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if __name__ == "__main__":
|
|
466
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|