gofannon 0.25.19__py3-none-any.whl → 0.25.21__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.
- gofannon/base/__init__.py +2 -0
- gofannon/base/adk_mixin.py +291 -0
- gofannon/config.py +2 -0
- gofannon/simpler_grants_gov/__init__.py +0 -0
- gofannon/simpler_grants_gov/base.py +125 -0
- gofannon/simpler_grants_gov/get_opportunity.py +66 -0
- gofannon/simpler_grants_gov/list_agencies.py +83 -0
- gofannon/simpler_grants_gov/query_by_applicant_eligibility.py +114 -0
- gofannon/simpler_grants_gov/query_by_assistance_listing.py +102 -0
- gofannon/simpler_grants_gov/query_by_award_criteria.py +117 -0
- gofannon/simpler_grants_gov/query_by_dates.py +108 -0
- gofannon/simpler_grants_gov/query_by_funding_details.py +115 -0
- gofannon/simpler_grants_gov/query_by_multiple_criteria.py +134 -0
- gofannon/simpler_grants_gov/query_opportunities.py +93 -0
- gofannon/simpler_grants_gov/query_opportunities_by_agency.py +104 -0
- gofannon/simpler_grants_gov/search_agencies.py +102 -0
- gofannon/simpler_grants_gov/search_base.py +167 -0
- gofannon/simpler_grants_gov/search_opportunities.py +360 -0
- {gofannon-0.25.19.dist-info → gofannon-0.25.21.dist-info}/METADATA +2 -2
- {gofannon-0.25.19.dist-info → gofannon-0.25.21.dist-info}/RECORD +22 -6
- {gofannon-0.25.19.dist-info → gofannon-0.25.21.dist-info}/LICENSE +0 -0
- {gofannon-0.25.19.dist-info → gofannon-0.25.21.dist-info}/WHEEL +0 -0
gofannon/base/__init__.py
CHANGED
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
8
8
|
|
9
9
|
import anyio
|
10
10
|
|
11
|
+
from .adk_mixin import AdkMixin
|
11
12
|
from ..config import ToolConfig
|
12
13
|
|
13
14
|
from .smol_agents import SmolAgentsMixin
|
@@ -74,6 +75,7 @@ class BaseTool(SmolAgentsMixin,
|
|
74
75
|
LangflowMixin,
|
75
76
|
MCPMixin,
|
76
77
|
LlamaStackMixin,
|
78
|
+
AdkMixin,
|
77
79
|
ABC):
|
78
80
|
def __init__(self, **kwargs):
|
79
81
|
self.logger = logging.getLogger(
|
@@ -0,0 +1,291 @@
|
|
1
|
+
# gofannon/base/adk_mixin.py
|
2
|
+
|
3
|
+
import inspect
|
4
|
+
import json
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Type as TypingType
|
6
|
+
|
7
|
+
# Try to import ADK components
|
8
|
+
try:
|
9
|
+
from google.adk.tools import BaseTool as AdkBaseTool
|
10
|
+
from google.adk.tools import FunctionTool as AdkFunctionTool
|
11
|
+
from google.adk.tools.tool_context import ToolContext as AdkToolContext
|
12
|
+
# _automatic_function_calling_util is not typically a public export,
|
13
|
+
# but FunctionTool uses it. For export, we might need a different strategy
|
14
|
+
# or rely on AdkFunctionTool to build its declaration.
|
15
|
+
# from google.adk.tools._automatic_function_calling_util import (
|
16
|
+
# build_function_declaration as adk_build_function_declaration,
|
17
|
+
# )
|
18
|
+
from google.genai import types as adk_gemini_types
|
19
|
+
import anyio # For running sync Gofannon fn in async ADK tool
|
20
|
+
|
21
|
+
_HAS_ADK = True
|
22
|
+
except ImportError:
|
23
|
+
_HAS_ADK = False
|
24
|
+
# Define dummy types for type hinting if ADK is not present
|
25
|
+
class AdkBaseTool: pass
|
26
|
+
class AdkFunctionTool(AdkBaseTool): pass # type: ignore
|
27
|
+
class AdkToolContext: pass # type: ignore
|
28
|
+
class adk_gemini_types: # type: ignore
|
29
|
+
class Type:
|
30
|
+
STRING = "STRING"
|
31
|
+
INTEGER = "INTEGER"
|
32
|
+
NUMBER = "NUMBER"
|
33
|
+
BOOLEAN = "BOOLEAN"
|
34
|
+
ARRAY = "ARRAY"
|
35
|
+
OBJECT = "OBJECT"
|
36
|
+
TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED"
|
37
|
+
class Schema: # type: ignore
|
38
|
+
def __init__(self, **kwargs):
|
39
|
+
self.type = kwargs.get('type', adk_gemini_types.Type.OBJECT)
|
40
|
+
self.description = kwargs.get('description')
|
41
|
+
self.properties = kwargs.get('properties', {})
|
42
|
+
self.items = kwargs.get('items')
|
43
|
+
self.required = kwargs.get('required', [])
|
44
|
+
self.enum = kwargs.get('enum')
|
45
|
+
self.nullable = kwargs.get('nullable')
|
46
|
+
|
47
|
+
class FunctionDeclaration: # type: ignore
|
48
|
+
def __init__(self, name, description, parameters):
|
49
|
+
self.name = name
|
50
|
+
self.description = description
|
51
|
+
self.parameters = parameters
|
52
|
+
|
53
|
+
|
54
|
+
# Helper for ADK Schema to Gofannon JSON Schema
|
55
|
+
ADK_GEMINI_TYPE_TO_JSON_TYPE = {
|
56
|
+
adk_gemini_types.Type.STRING: "string",
|
57
|
+
adk_gemini_types.Type.INTEGER: "integer",
|
58
|
+
adk_gemini_types.Type.NUMBER: "number",
|
59
|
+
adk_gemini_types.Type.BOOLEAN: "boolean",
|
60
|
+
adk_gemini_types.Type.ARRAY: "array",
|
61
|
+
adk_gemini_types.Type.OBJECT: "object",
|
62
|
+
adk_gemini_types.Type.TYPE_UNSPECIFIED: "object", # Default for unspecified
|
63
|
+
}
|
64
|
+
|
65
|
+
def _adk_schema_to_gofannon_json_schema(adk_schema: Optional[adk_gemini_types.Schema]) -> Dict[str, Any]:
|
66
|
+
if not adk_schema:
|
67
|
+
return {"type": "object", "properties": {}}
|
68
|
+
|
69
|
+
json_schema: Dict[str, Any] = {}
|
70
|
+
|
71
|
+
adk_type_enum = getattr(adk_schema, 'type', adk_gemini_types.Type.TYPE_UNSPECIFIED)
|
72
|
+
json_type_str = ADK_GEMINI_TYPE_TO_JSON_TYPE.get(adk_type_enum, "object")
|
73
|
+
|
74
|
+
if getattr(adk_schema, 'nullable', False):
|
75
|
+
# Represent nullable as a list of types including "null" if original type is singular
|
76
|
+
# or handle it based on how Gofannon expects nullable
|
77
|
+
json_schema["type"] = [json_type_str, "null"] if json_type_str != "object" else json_type_str # Pydantic v1 style for Optional[T]
|
78
|
+
if json_type_str == "object": # For objects, nullable flag is more common in JSON schema
|
79
|
+
json_schema["nullable"] = True
|
80
|
+
json_schema["type"] = "object" # Keep type as object if it was object
|
81
|
+
else:
|
82
|
+
json_schema["type"] = json_type_str
|
83
|
+
|
84
|
+
description = getattr(adk_schema, 'description', None)
|
85
|
+
if description:
|
86
|
+
json_schema["description"] = description
|
87
|
+
|
88
|
+
if adk_type_enum == adk_gemini_types.Type.OBJECT:
|
89
|
+
properties = getattr(adk_schema, 'properties', None)
|
90
|
+
if properties:
|
91
|
+
json_schema["properties"] = {
|
92
|
+
name: _adk_schema_to_gofannon_json_schema(prop_schema)
|
93
|
+
for name, prop_schema in properties.items()
|
94
|
+
}
|
95
|
+
else:
|
96
|
+
json_schema["properties"] = {} # Ensure properties exist for object type
|
97
|
+
|
98
|
+
items = getattr(adk_schema, 'items', None)
|
99
|
+
if adk_type_enum == adk_gemini_types.Type.ARRAY and items:
|
100
|
+
json_schema["items"] = _adk_schema_to_gofannon_json_schema(items)
|
101
|
+
|
102
|
+
required_list = getattr(adk_schema, 'required', None)
|
103
|
+
if required_list:
|
104
|
+
json_schema["required"] = list(required_list)
|
105
|
+
|
106
|
+
enum_list = getattr(adk_schema, 'enum', None)
|
107
|
+
if enum_list:
|
108
|
+
json_schema["enum"] = list(enum_list)
|
109
|
+
|
110
|
+
# Ensure "properties" field exists if type is "object"
|
111
|
+
if json_schema.get("type") == "object" and "properties" not in json_schema:
|
112
|
+
json_schema["properties"] = {}
|
113
|
+
|
114
|
+
return json_schema
|
115
|
+
|
116
|
+
# Helper for Gofannon JSON Schema to ADK Schema
|
117
|
+
JSON_TYPE_TO_ADK_GEMINI_TYPE = {
|
118
|
+
"string": adk_gemini_types.Type.STRING,
|
119
|
+
"integer": adk_gemini_types.Type.INTEGER,
|
120
|
+
"number": adk_gemini_types.Type.NUMBER,
|
121
|
+
"boolean": adk_gemini_types.Type.BOOLEAN,
|
122
|
+
"array": adk_gemini_types.Type.ARRAY,
|
123
|
+
"object": adk_gemini_types.Type.OBJECT,
|
124
|
+
"null": adk_gemini_types.Type.TYPE_UNSPECIFIED, # ADK has no 'null' type, unspecified is closest
|
125
|
+
}
|
126
|
+
|
127
|
+
def _gofannon_json_schema_to_adk_schema(json_schema: Dict[str, Any]) -> adk_gemini_types.Schema:
|
128
|
+
if not json_schema: # Handles empty dict {} case
|
129
|
+
return adk_gemini_types.Schema(type=adk_gemini_types.Type.OBJECT, properties={})
|
130
|
+
|
131
|
+
adk_schema_kwargs: Dict[str, Any] = {}
|
132
|
+
|
133
|
+
json_type_val = json_schema.get("type", "object")
|
134
|
+
is_nullable = json_schema.get("nullable", False) # Check for explicit "nullable"
|
135
|
+
|
136
|
+
actual_json_type_str = json_type_val
|
137
|
+
if isinstance(json_type_val, list): # Handles type: ["string", "null"]
|
138
|
+
if "null" in json_type_val:
|
139
|
+
is_nullable = True
|
140
|
+
actual_json_type_str = next((t for t in json_type_val if t != "null"), "object")
|
141
|
+
|
142
|
+
adk_type_enum = JSON_TYPE_TO_ADK_GEMINI_TYPE.get(str(actual_json_type_str).lower(), adk_gemini_types.Type.OBJECT)
|
143
|
+
adk_schema_kwargs["type"] = adk_type_enum
|
144
|
+
if is_nullable:
|
145
|
+
adk_schema_kwargs["nullable"] = True
|
146
|
+
|
147
|
+
if "description" in json_schema:
|
148
|
+
adk_schema_kwargs["description"] = json_schema["description"]
|
149
|
+
|
150
|
+
if adk_type_enum == adk_gemini_types.Type.OBJECT and "properties" in json_schema:
|
151
|
+
adk_schema_kwargs["properties"] = {
|
152
|
+
name: _gofannon_json_schema_to_adk_schema(prop_schema)
|
153
|
+
for name, prop_schema in json_schema["properties"].items()
|
154
|
+
}
|
155
|
+
elif adk_type_enum == adk_gemini_types.Type.OBJECT: # Ensure properties for object type
|
156
|
+
adk_schema_kwargs["properties"] = {}
|
157
|
+
|
158
|
+
if adk_type_enum == adk_gemini_types.Type.ARRAY and "items" in json_schema:
|
159
|
+
adk_schema_kwargs["items"] = _gofannon_json_schema_to_adk_schema(json_schema["items"])
|
160
|
+
|
161
|
+
if "required" in json_schema:
|
162
|
+
adk_schema_kwargs["required"] = list(json_schema["required"])
|
163
|
+
|
164
|
+
if "enum" in json_schema:
|
165
|
+
adk_schema_kwargs["enum"] = list(json_schema["enum"])
|
166
|
+
|
167
|
+
return adk_gemini_types.Schema(**adk_schema_kwargs)
|
168
|
+
|
169
|
+
|
170
|
+
class AdkMixin:
|
171
|
+
def import_from_adk(self, adk_tool: AdkBaseTool):
|
172
|
+
"""
|
173
|
+
Adapts a google-adk-python tool to the Gofannon BaseTool structure.
|
174
|
+
|
175
|
+
Args:
|
176
|
+
adk_tool: An instance of a class derived from `google.adk.tools.BaseTool`.
|
177
|
+
"""
|
178
|
+
if not _HAS_ADK:
|
179
|
+
raise RuntimeError(
|
180
|
+
"google-adk-python is not installed. Install with `pip install google-adk`"
|
181
|
+
)
|
182
|
+
if not isinstance(adk_tool, AdkBaseTool): # type: ignore
|
183
|
+
raise TypeError("Input must be an instance of ADK BaseTool.")
|
184
|
+
|
185
|
+
self.name = adk_tool.name # type: ignore
|
186
|
+
self.description = adk_tool.description # type: ignore
|
187
|
+
|
188
|
+
declaration = None
|
189
|
+
# Ensure _get_declaration is callable and attempt to call it
|
190
|
+
if hasattr(adk_tool, "_get_declaration") and callable(adk_tool._get_declaration): # type: ignore
|
191
|
+
try:
|
192
|
+
declaration = adk_tool._get_declaration() # type: ignore
|
193
|
+
except Exception as e:
|
194
|
+
self.logger.warning(f"Could not get declaration from ADK tool {self.name}: {e}. Assuming no parameters.") # type: ignore
|
195
|
+
|
196
|
+
gofannon_params_schema: Dict[str, Any] = {"type": "object", "properties": {}}
|
197
|
+
if declaration and hasattr(declaration, 'parameters') and declaration.parameters:
|
198
|
+
gofannon_params_schema = _adk_schema_to_gofannon_json_schema(declaration.parameters)
|
199
|
+
|
200
|
+
self.definition = { # type: ignore
|
201
|
+
"function": {
|
202
|
+
"name": self.name,
|
203
|
+
"description": self.description,
|
204
|
+
"parameters": gofannon_params_schema
|
205
|
+
}
|
206
|
+
}
|
207
|
+
|
208
|
+
# Adapt the execution logic
|
209
|
+
if isinstance(adk_tool, AdkFunctionTool) and hasattr(adk_tool, 'func'): # type: ignore
|
210
|
+
target_callable = adk_tool.func # type: ignore
|
211
|
+
self.fn = target_callable # type: ignore
|
212
|
+
elif hasattr(adk_tool, 'run_async') and callable(adk_tool.run_async): # type: ignore
|
213
|
+
self.logger.warning( # type: ignore
|
214
|
+
f"Importing ADK tool {self.name} that is not a FunctionTool. "
|
215
|
+
f"ADK ToolContext features will not be available or may require a dummy context. "
|
216
|
+
f"Ensure this tool can operate correctly with args only."
|
217
|
+
)
|
218
|
+
# This wrapper will become self.fn. If self.fn is async, Gofannon's
|
219
|
+
# execute_async can await it directly. Gofannon's sync execute
|
220
|
+
# would need to handle running this async fn (e.g., using anyio.run).
|
221
|
+
async def adk_run_async_wrapper(**kwargs):
|
222
|
+
# This simplified call assumes the tool can function with a None ToolContext
|
223
|
+
# or that its core logic doesn't strictly depend on it.
|
224
|
+
return await adk_tool.run_async(args=kwargs, tool_context=None) # type: ignore
|
225
|
+
self.fn = adk_run_async_wrapper # type: ignore
|
226
|
+
else:
|
227
|
+
self.logger.error( # type: ignore
|
228
|
+
f"ADK tool {self.name} does not have a suitable execution method ('func' or 'run_async')."
|
229
|
+
)
|
230
|
+
def placeholder_fn(**kwargs):
|
231
|
+
raise NotImplementedError(f"Execution for imported ADK tool {self.name} is not available.")
|
232
|
+
self.fn = placeholder_fn # type: ignore
|
233
|
+
|
234
|
+
|
235
|
+
def export_to_adk(self) -> AdkBaseTool:
|
236
|
+
"""
|
237
|
+
Converts the Gofannon tool to a google-adk-python compatible tool.
|
238
|
+
This typically creates a custom AdkBaseTool derivative that uses the
|
239
|
+
Gofannon tool's definition and execution logic.
|
240
|
+
|
241
|
+
Returns:
|
242
|
+
An instance of a `google.adk.tools.BaseTool` derivative.
|
243
|
+
"""
|
244
|
+
if not _HAS_ADK:
|
245
|
+
raise RuntimeError(
|
246
|
+
"google-adk-python is not installed. Install with `pip install google-adk`"
|
247
|
+
)
|
248
|
+
|
249
|
+
gofannon_def = self.definition.get("function", {}) # type: ignore
|
250
|
+
tool_name = gofannon_def.get("name", getattr(self, "name", "gofannon_exported_tool"))
|
251
|
+
tool_description = gofannon_def.get("description", getattr(self, "description", "No description provided."))
|
252
|
+
|
253
|
+
gofannon_params_schema = gofannon_def.get("parameters", {"type": "object", "properties": {}})
|
254
|
+
|
255
|
+
original_gofannon_fn = self.fn # type: ignore
|
256
|
+
is_gofannon_fn_async = inspect.iscoroutinefunction(original_gofannon_fn)
|
257
|
+
|
258
|
+
# Define a custom ADK Tool class
|
259
|
+
class GofannonAdkTool(AdkBaseTool): # type: ignore
|
260
|
+
def __init__(self, name, description, gofannon_json_schema, gofannon_exec_fn, is_fn_async):
|
261
|
+
super().__init__(name=name, description=description) # type: ignore
|
262
|
+
self._gofannon_json_schema = gofannon_json_schema
|
263
|
+
self._gofannon_exec_fn = gofannon_exec_fn
|
264
|
+
self._is_fn_async = is_fn_async
|
265
|
+
|
266
|
+
def _get_declaration(self) -> Optional[adk_gemini_types.FunctionDeclaration]: # type: ignore
|
267
|
+
adk_params_schema = _gofannon_json_schema_to_adk_schema(self._gofannon_json_schema)
|
268
|
+
return adk_gemini_types.FunctionDeclaration( # type: ignore
|
269
|
+
name=self.name,
|
270
|
+
description=self.description,
|
271
|
+
parameters=adk_params_schema
|
272
|
+
)
|
273
|
+
|
274
|
+
async def run_async(self, *, args: Dict[str, Any], tool_context: AdkToolContext) -> Any: # type: ignore
|
275
|
+
# The ADK tool_context is available here but the Gofannon fn doesn't expect it.
|
276
|
+
# We simply pass the args to the Gofannon function.
|
277
|
+
if self._is_fn_async:
|
278
|
+
return await self._gofannon_exec_fn(**args)
|
279
|
+
else:
|
280
|
+
# Gofannon's synchronous fn needs to be run in a thread
|
281
|
+
# as ADK's run_async is an async method.
|
282
|
+
return await anyio.to_thread.run_sync(self._gofannon_exec_fn, **args) # type: ignore
|
283
|
+
|
284
|
+
exported_adk_tool = GofannonAdkTool(
|
285
|
+
name=tool_name,
|
286
|
+
description=tool_description,
|
287
|
+
gofannon_json_schema=gofannon_params_schema,
|
288
|
+
gofannon_exec_fn=original_gofannon_fn,
|
289
|
+
is_fn_async=is_gofannon_fn_async
|
290
|
+
)
|
291
|
+
return exported_adk_tool
|
gofannon/config.py
CHANGED
@@ -15,6 +15,8 @@ class ToolConfig:
|
|
15
15
|
'google_search_api_key': os.getenv('GOOGLE_SEARCH_API_KEY'),
|
16
16
|
'google_search_engine_id': os.getenv('GOOGLE_SEARCH_ENGINE_ID'),
|
17
17
|
'nasa_apod_api_key': os.getenv('NASA_APOD_API_KEY'),
|
18
|
+
'simpler_grants_api_key': os.getenv('SIMPLER_GRANTS_API_KEY'),
|
19
|
+
'simpler_grants_base_url': os.getenv('SIMPLER_GRANTS_BASE_URL', 'https://api.simpler.grants.gov'), #configurable as key in dev, maybe someone wants to test?
|
18
20
|
}
|
19
21
|
|
20
22
|
@classmethod
|
File without changes
|
@@ -0,0 +1,125 @@
|
|
1
|
+
import requests
|
2
|
+
import logging
|
3
|
+
import json
|
4
|
+
from typing import Optional, Dict, Any
|
5
|
+
|
6
|
+
from ..base import BaseTool
|
7
|
+
from ..config import ToolConfig
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
class SimplerGrantsGovBase(BaseTool):
|
12
|
+
"""
|
13
|
+
Base class for tools interacting with the Simpler Grants Gov API.
|
14
|
+
|
15
|
+
Handles common setup like API key and base URL management, and provides
|
16
|
+
a helper method for making authenticated requests.
|
17
|
+
"""
|
18
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, **kwargs):
|
19
|
+
super().__init__(**kwargs)
|
20
|
+
self.api_key = api_key or ToolConfig.get("simpler_grants_api_key")
|
21
|
+
self.base_url = base_url or ToolConfig.get("simpler_grants_base_url")
|
22
|
+
|
23
|
+
if not self.api_key:
|
24
|
+
msg = "Simpler Grants Gov API key is missing. Please set SIMPLER_GRANTS_API_KEY environment variable or pass api_key argument."
|
25
|
+
logger.error(msg)
|
26
|
+
# Decide on behavior: raise error or allow initialization but fail on execution?
|
27
|
+
# Raising an error early is often clearer.
|
28
|
+
# raise ValueError(msg)
|
29
|
+
# Or, log and proceed, letting _make_request handle the missing key later.
|
30
|
+
self.logger.warning(msg + " Tool execution will likely fail.")
|
31
|
+
|
32
|
+
|
33
|
+
if not self.base_url:
|
34
|
+
msg = "Simpler Grants Gov base URL is missing. Please set SIMPLER_GRANTS_BASE_URL environment variable or pass base_url argument."
|
35
|
+
logger.error(msg)
|
36
|
+
# raise ValueError(msg)
|
37
|
+
self.logger.warning(msg + " Tool execution will likely fail.")
|
38
|
+
|
39
|
+
self.logger.debug(f"Initialized {self.__class__.__name__} with base_url: {self.base_url} and API key {'present' if self.api_key else 'missing'}")
|
40
|
+
|
41
|
+
|
42
|
+
def _make_request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, json_payload: Optional[Dict[str, Any]] = None) -> str:
|
43
|
+
"""
|
44
|
+
Makes an authenticated request to the Simpler Grants Gov API.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
method: HTTP method (e.g., 'GET', 'POST').
|
48
|
+
endpoint: API endpoint path (e.g., '/v1/opportunities/search').
|
49
|
+
params: URL query parameters.
|
50
|
+
json_payload: JSON body for POST/PUT requests.
|
51
|
+
|
52
|
+
Returns:
|
53
|
+
The JSON response content as a string.
|
54
|
+
|
55
|
+
Raises:
|
56
|
+
requests.exceptions.RequestException: If the request fails.
|
57
|
+
ValueError: If API key or base URL is missing.
|
58
|
+
"""
|
59
|
+
if not self.api_key:
|
60
|
+
raise ValueError("Simpler Grants Gov API key is missing.")
|
61
|
+
if not self.base_url:
|
62
|
+
raise ValueError("Simpler Grants Gov base URL is missing.")
|
63
|
+
|
64
|
+
full_url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
65
|
+
headers = {
|
66
|
+
# Based on api_key_auth.py, the API expects the key in this header
|
67
|
+
'X-Auth': self.api_key,
|
68
|
+
'Content-Type': 'application/json',
|
69
|
+
'accept': 'application/json'
|
70
|
+
}
|
71
|
+
|
72
|
+
self.logger.debug(f"Making {method} request to {full_url}")
|
73
|
+
self.logger.debug(f"Headers: {{'X-Auth': '***', 'Content-Type': 'application/json', 'accept': 'application/json'}}") # Don't log key
|
74
|
+
if params:
|
75
|
+
self.logger.debug(f"Params: {params}")
|
76
|
+
if json_payload:
|
77
|
+
# Be careful logging potentially large/sensitive payloads
|
78
|
+
log_payload = json.dumps(json_payload)[:500] # Log truncated payload
|
79
|
+
self.logger.debug(f"JSON Payload (truncated): {log_payload}")
|
80
|
+
|
81
|
+
|
82
|
+
try:
|
83
|
+
response = requests.request(
|
84
|
+
method,
|
85
|
+
full_url,
|
86
|
+
headers=headers,
|
87
|
+
params=params,
|
88
|
+
json=json_payload,
|
89
|
+
timeout=30 # Add a reasonable timeout
|
90
|
+
)
|
91
|
+
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
92
|
+
|
93
|
+
# Check if response is empty or not JSON before trying to parse
|
94
|
+
content_type = response.headers.get('Content-Type', '')
|
95
|
+
if response.content and 'application/json' in content_type:
|
96
|
+
try:
|
97
|
+
# Return raw text which usually includes JSON string
|
98
|
+
return response.text
|
99
|
+
except json.JSONDecodeError as e:
|
100
|
+
self.logger.error(f"Failed to decode JSON response from {full_url}. Status: {response.status_code}. Content: {response.text[:500]}... Error: {e}")
|
101
|
+
# Return raw text even if not JSON, could be an error message
|
102
|
+
return response.text
|
103
|
+
elif response.content:
|
104
|
+
self.logger.warning(f"Response from {full_url} is not JSON (Content-Type: {content_type}). Returning raw text.")
|
105
|
+
return response.text
|
106
|
+
else:
|
107
|
+
self.logger.warning(f"Received empty response from {full_url}. Status: {response.status_code}")
|
108
|
+
return "" # Return empty string for empty response
|
109
|
+
|
110
|
+
|
111
|
+
except requests.exceptions.RequestException as e:
|
112
|
+
self.logger.error(f"Request to {full_url} failed: {e}")
|
113
|
+
# Re-raise the exception to be handled by the BaseTool's execute method
|
114
|
+
# Or return a formatted error string
|
115
|
+
# return json.dumps({"error": f"API request failed: {e}"})
|
116
|
+
raise # Re-raise for BaseTool's error handling
|
117
|
+
|
118
|
+
|
119
|
+
# Subclasses must implement definition and fn
|
120
|
+
@property
|
121
|
+
def definition(self):
|
122
|
+
raise NotImplementedError("Subclasses must implement the 'definition' property.")
|
123
|
+
|
124
|
+
def fn(self, *args, **kwargs):
|
125
|
+
raise NotImplementedError("Subclasses must implement the 'fn' method.")
|
@@ -0,0 +1,66 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import Optional
|
3
|
+
import json
|
4
|
+
|
5
|
+
from .base import SimplerGrantsGovBase
|
6
|
+
from ..config import FunctionRegistry
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
@FunctionRegistry.register
|
11
|
+
class GetOpportunity(SimplerGrantsGovBase):
|
12
|
+
"""
|
13
|
+
Tool to retrieve details for a specific grant opportunity by its ID.
|
14
|
+
Corresponds to the GET /v1/opportunities/{opportunity_id} endpoint.
|
15
|
+
"""
|
16
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, name: str = "get_opportunity"):
|
17
|
+
super().__init__(api_key=api_key, base_url=base_url)
|
18
|
+
self.name = name
|
19
|
+
|
20
|
+
@property
|
21
|
+
def definition(self):
|
22
|
+
# Based on route GET /v1/opportunities/{opportunity_id} and OpportunityGetResponseV1Schema
|
23
|
+
return {
|
24
|
+
"type": "function",
|
25
|
+
"function": {
|
26
|
+
"name": self.name,
|
27
|
+
"description": "Retrieve the full details of a specific grant opportunity, including attachments, using its unique identifier.",
|
28
|
+
"parameters": {
|
29
|
+
"type": "object",
|
30
|
+
"properties": {
|
31
|
+
"opportunity_id": {
|
32
|
+
"type": "integer",
|
33
|
+
"description": "The unique numeric identifier for the grant opportunity."
|
34
|
+
}
|
35
|
+
},
|
36
|
+
"required": ["opportunity_id"]
|
37
|
+
}
|
38
|
+
}
|
39
|
+
}
|
40
|
+
|
41
|
+
def fn(self, opportunity_id: int) -> str:
|
42
|
+
"""
|
43
|
+
Executes the get opportunity request.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
opportunity_id: The ID of the opportunity to retrieve.
|
47
|
+
|
48
|
+
Returns:
|
49
|
+
A JSON string representing the opportunity details.
|
50
|
+
"""
|
51
|
+
self.logger.info(f"Executing Simpler Grants Gov get opportunity tool for ID: {opportunity_id}")
|
52
|
+
|
53
|
+
if not isinstance(opportunity_id, int) or opportunity_id <= 0:
|
54
|
+
# Add validation for the ID
|
55
|
+
self.logger.error(f"Invalid opportunity_id provided: {opportunity_id}. Must be a positive integer.")
|
56
|
+
return json.dumps({"error": "Invalid opportunity_id provided. Must be a positive integer.", "success": False})
|
57
|
+
|
58
|
+
endpoint = f"/v1/opportunities/{opportunity_id}"
|
59
|
+
try:
|
60
|
+
result = self._make_request("GET", endpoint)
|
61
|
+
self.logger.debug(f"Get opportunity successful for ID {opportunity_id}. Response length: {len(result)}")
|
62
|
+
return result
|
63
|
+
except Exception as e:
|
64
|
+
self.logger.error(f"Get opportunity failed for ID {opportunity_id}: {e}", exc_info=True)
|
65
|
+
# Return a JSON error string
|
66
|
+
return json.dumps({"error": f"Get opportunity failed: {str(e)}", "success": False})
|
@@ -0,0 +1,83 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import Optional, Dict, Any
|
3
|
+
import json
|
4
|
+
|
5
|
+
from .base import SimplerGrantsGovBase
|
6
|
+
from ..config import FunctionRegistry
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
@FunctionRegistry.register
|
11
|
+
class ListAgencies(SimplerGrantsGovBase):
|
12
|
+
"""
|
13
|
+
Tool to retrieve a list of agencies, potentially filtered.
|
14
|
+
Corresponds to the POST /v1/agencies endpoint.
|
15
|
+
NOTE: The API uses POST for listing/filtering, not GET.
|
16
|
+
"""
|
17
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, name: str = "list_agencies"):
|
18
|
+
super().__init__(api_key=api_key, base_url=base_url)
|
19
|
+
self.name = name
|
20
|
+
|
21
|
+
@property
|
22
|
+
def definition(self):
|
23
|
+
# Based on AgencyListRequestSchema
|
24
|
+
return {
|
25
|
+
"type": "function",
|
26
|
+
"function": {
|
27
|
+
"name": self.name,
|
28
|
+
"description": "Retrieve a paginated list of agencies, optionally filtered by agency ID or active status.",
|
29
|
+
"parameters": {
|
30
|
+
"type": "object",
|
31
|
+
"properties": {
|
32
|
+
"filters": {
|
33
|
+
"type": "object",
|
34
|
+
"description": "Optional. A JSON object for filtering. Can contain 'agency_id' (UUID string) or 'active' (boolean).",
|
35
|
+
"properties": {
|
36
|
+
"agency_id": {"type": "string", "format": "uuid"},
|
37
|
+
"active": {"type": "boolean"}
|
38
|
+
}
|
39
|
+
},
|
40
|
+
"pagination": {
|
41
|
+
"type": "object",
|
42
|
+
"description": "Required. A JSON object for pagination. Must include 'page_offset', 'page_size', and 'sort_order' (array of objects with 'order_by': ['agency_code', 'agency_name', 'created_at'] and 'sort_direction': ['ascending', 'descending']).",
|
43
|
+
"properties": {
|
44
|
+
"page_offset": {"type": "integer", "description": "Page number (starts at 1)."},
|
45
|
+
"page_size": {"type": "integer", "description": "Results per page."},
|
46
|
+
"sort_order": {
|
47
|
+
"type": "array",
|
48
|
+
"items": {
|
49
|
+
"type": "object",
|
50
|
+
"properties": {
|
51
|
+
"order_by": {"type": "string", "enum": ["agency_code", "agency_name", "created_at"]},
|
52
|
+
"sort_direction": {"type": "string", "enum": ["ascending", "descending"]}
|
53
|
+
},
|
54
|
+
"required": ["order_by", "sort_direction"]
|
55
|
+
}
|
56
|
+
}
|
57
|
+
},
|
58
|
+
"required": ["page_offset", "page_size", "sort_order"]
|
59
|
+
}
|
60
|
+
},
|
61
|
+
"required": ["pagination"]
|
62
|
+
}
|
63
|
+
}
|
64
|
+
}
|
65
|
+
|
66
|
+
def fn(self, pagination: Dict[str, Any], filters: Optional[Dict[str, Any]] = None) -> str:
|
67
|
+
"""
|
68
|
+
Executes the list agencies request.
|
69
|
+
"""
|
70
|
+
self.logger.info("Executing Simpler Grants Gov list agencies tool")
|
71
|
+
payload = {"pagination": pagination}
|
72
|
+
if filters:
|
73
|
+
payload["filters"] = filters
|
74
|
+
|
75
|
+
endpoint = "/v1/agencies"
|
76
|
+
try:
|
77
|
+
result = self._make_request("POST", endpoint, json_payload=payload)
|
78
|
+
self.logger.debug(f"List agencies successful. Response length: {len(result)}")
|
79
|
+
return result
|
80
|
+
except Exception as e:
|
81
|
+
self.logger.error(f"List agencies failed: {e}", exc_info=True)
|
82
|
+
return json.dumps({"error": f"List agencies failed: {str(e)}", "success": False})
|
83
|
+
|