gofannon 0.25.18__py3-none-any.whl → 0.25.20__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/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.18.dist-info → gofannon-0.25.20.dist-info}/METADATA +1 -1
- {gofannon-0.25.18.dist-info → gofannon-0.25.20.dist-info}/RECORD +20 -5
- {gofannon-0.25.18.dist-info → gofannon-0.25.20.dist-info}/WHEEL +1 -1
- {gofannon-0.25.18.dist-info → gofannon-0.25.20.dist-info}/LICENSE +0 -0
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
|
+
|
@@ -0,0 +1,114 @@
|
|
1
|
+
import logging
|
2
|
+
import json
|
3
|
+
from typing import Optional, Dict, Any, List
|
4
|
+
|
5
|
+
from .search_base import SearchOpportunitiesBase
|
6
|
+
from ..config import FunctionRegistry
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
@FunctionRegistry.register
|
11
|
+
class QueryByApplicantEligibility(SearchOpportunitiesBase):
|
12
|
+
"""
|
13
|
+
Tool to search for grant opportunities based on applicant types and/or cost-sharing requirements.
|
14
|
+
"""
|
15
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, name: str = "query_opportunities_by_applicant_eligibility"):
|
16
|
+
super().__init__(api_key=api_key, base_url=base_url, name=name)
|
17
|
+
self.name = name
|
18
|
+
|
19
|
+
@property
|
20
|
+
def definition(self):
|
21
|
+
base_params = self._get_common_parameter_definitions()
|
22
|
+
specific_params = {
|
23
|
+
"applicant_types": {
|
24
|
+
"type": "array",
|
25
|
+
"items": {"type": "string"},
|
26
|
+
"description": "Optional. List of applicant types (e.g., ['state_governments', 'nonprofits_with_501c3']). Valid values: "
|
27
|
+
"city_or_township_governments, county_governments, federal_government_agencies_fed_recognized_tribes_excluded, "
|
28
|
+
"independent_school_districts, individuals, native_american_tribal_governments_federally_recognized, "
|
29
|
+
"native_american_tribal_organizations_other_than_federally_recognized_tribal_governments, "
|
30
|
+
"nonprofits_having_a_501c3_status_with_the_irs_other_than_institutions_of_higher_education, "
|
31
|
+
"nonprofits_that_do_not_have_a_501c3_status_with_the_irs_other_than_institutions_of_higher_education, "
|
32
|
+
"private_institutions_of_higher_education, public_and_state_controlled_institutions_of_higher_education, "
|
33
|
+
"public_housing_authorities_or_indian_housing_authorities, small_businesses, special_district_governments, state_governments, other."
|
34
|
+
},
|
35
|
+
"requires_cost_sharing": { # Parameter name is more direct for LLM
|
36
|
+
"type": "boolean",
|
37
|
+
"description": "Optional. Filter by cost-sharing requirement. True for opportunities requiring cost sharing, False for those not requiring it. Omit to not filter by this."
|
38
|
+
},
|
39
|
+
"query_text": {
|
40
|
+
"type": "string",
|
41
|
+
"description": "Optional. Text to search for within the results filtered by eligibility."
|
42
|
+
},
|
43
|
+
"query_operator": {
|
44
|
+
"type": "string",
|
45
|
+
"enum": ["AND", "OR"],
|
46
|
+
"description": "Operator for 'query_text' if provided (default: AND).",
|
47
|
+
"default": "AND"
|
48
|
+
}
|
49
|
+
}
|
50
|
+
all_properties = {**specific_params, **base_params}
|
51
|
+
|
52
|
+
return {
|
53
|
+
"type": "function",
|
54
|
+
"function": {
|
55
|
+
"name": self.name,
|
56
|
+
"description": (
|
57
|
+
"Search for grant opportunities by applicant types and/or cost-sharing requirements. "
|
58
|
+
"At least one of 'applicant_types' or 'requires_cost_sharing' must be specified if used for filtering. "
|
59
|
+
f"{self._pagination_description}"
|
60
|
+
f"{self._status_filter_description}"
|
61
|
+
f"{self._common_search_description_suffix}"
|
62
|
+
),
|
63
|
+
"parameters": {
|
64
|
+
"type": "object",
|
65
|
+
"properties": all_properties,
|
66
|
+
"required": [] # Logic in fn enforces condition
|
67
|
+
}
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
def fn(self,
|
72
|
+
applicant_types: Optional[List[str]] = None,
|
73
|
+
requires_cost_sharing: Optional[bool] = None,
|
74
|
+
query_text: Optional[str] = None,
|
75
|
+
query_operator: str = "AND",
|
76
|
+
# Common params
|
77
|
+
items_per_page: int = 5,
|
78
|
+
page_number: int = 1,
|
79
|
+
order_by: str = "relevancy",
|
80
|
+
sort_direction: str = "descending",
|
81
|
+
show_posted: bool = True,
|
82
|
+
show_forecasted: bool = False,
|
83
|
+
show_closed: bool = False,
|
84
|
+
show_archived: bool = False) -> str:
|
85
|
+
|
86
|
+
self.logger.info(f"Querying by applicant eligibility: types={applicant_types}, cost_sharing={requires_cost_sharing}, query='{query_text}'")
|
87
|
+
|
88
|
+
if applicant_types is None and requires_cost_sharing is None and not query_text: # if no specific filters or query, it's too broad
|
89
|
+
return json.dumps({"error": "Please provide 'applicant_types', 'requires_cost_sharing', or 'query_text' to refine the search.", "success": False})
|
90
|
+
|
91
|
+
specific_filters: Dict[str, Any] = {}
|
92
|
+
if applicant_types:
|
93
|
+
specific_filters["applicant_type"] = {"one_of": applicant_types}
|
94
|
+
if requires_cost_sharing is not None: # API expects list for boolean filters
|
95
|
+
specific_filters["is_cost_sharing"] = {"one_of": [requires_cost_sharing]}
|
96
|
+
|
97
|
+
specific_query_params: Optional[Dict[str, Any]] = None
|
98
|
+
if query_text:
|
99
|
+
specific_query_params = {"query": query_text}
|
100
|
+
|
101
|
+
payload = self._build_api_payload(
|
102
|
+
specific_query_params=specific_query_params,
|
103
|
+
specific_filters=specific_filters,
|
104
|
+
items_per_page=items_per_page,
|
105
|
+
page_number=page_number,
|
106
|
+
order_by=order_by,
|
107
|
+
sort_direction=sort_direction,
|
108
|
+
show_posted=show_posted,
|
109
|
+
show_forecasted=show_forecasted,
|
110
|
+
show_closed=show_closed,
|
111
|
+
show_archived=show_archived,
|
112
|
+
query_operator=query_operator
|
113
|
+
)
|
114
|
+
return self._execute_search(payload)
|
@@ -0,0 +1,102 @@
|
|
1
|
+
import logging
|
2
|
+
import json
|
3
|
+
from typing import Optional, Dict, Any, List
|
4
|
+
|
5
|
+
from .search_base import SearchOpportunitiesBase
|
6
|
+
from ..config import FunctionRegistry
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
@FunctionRegistry.register
|
11
|
+
class QueryByAssistanceListing(SearchOpportunitiesBase):
|
12
|
+
"""
|
13
|
+
Tool to search for grant opportunities by Assistance Listing Numbers (formerly CFDA numbers).
|
14
|
+
Numbers should be in the format '##.###' or '##.##'.
|
15
|
+
"""
|
16
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, name: str = "query_opportunities_by_assistance_listing"):
|
17
|
+
super().__init__(api_key=api_key, base_url=base_url, name=name)
|
18
|
+
self.name = name
|
19
|
+
|
20
|
+
@property
|
21
|
+
def definition(self):
|
22
|
+
base_params = self._get_common_parameter_definitions()
|
23
|
+
specific_params = {
|
24
|
+
"assistance_listing_numbers": {
|
25
|
+
"type": "array",
|
26
|
+
"items": {"type": "string"},
|
27
|
+
"description": "List of Assistance Listing Numbers (e.g., ['10.001', '93.123']). Must match pattern ##.### or ##.##."
|
28
|
+
},
|
29
|
+
"query_text": {
|
30
|
+
"type": "string",
|
31
|
+
"description": "Optional. Text to search for within the results filtered by assistance listing."
|
32
|
+
},
|
33
|
+
"query_operator": {
|
34
|
+
"type": "string",
|
35
|
+
"enum": ["AND", "OR"],
|
36
|
+
"description": "Operator for 'query_text' if provided (default: AND).",
|
37
|
+
"default": "AND"
|
38
|
+
}
|
39
|
+
}
|
40
|
+
all_properties = {**specific_params, **base_params}
|
41
|
+
|
42
|
+
return {
|
43
|
+
"type": "function",
|
44
|
+
"function": {
|
45
|
+
"name": self.name,
|
46
|
+
"description": (
|
47
|
+
"Search for grant opportunities by one or more Assistance Listing Numbers. "
|
48
|
+
f"{self._pagination_description}"
|
49
|
+
f"{self._status_filter_description}"
|
50
|
+
f"{self._common_search_description_suffix}"
|
51
|
+
),
|
52
|
+
"parameters": {
|
53
|
+
"type": "object",
|
54
|
+
"properties": all_properties,
|
55
|
+
"required": ["assistance_listing_numbers"]
|
56
|
+
}
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
def fn(self,
|
61
|
+
assistance_listing_numbers: List[str],
|
62
|
+
query_text: Optional[str] = None,
|
63
|
+
query_operator: str = "AND",
|
64
|
+
# Common params
|
65
|
+
items_per_page: int = 5,
|
66
|
+
page_number: int = 1,
|
67
|
+
order_by: str = "relevancy",
|
68
|
+
sort_direction: str = "descending",
|
69
|
+
show_posted: bool = True,
|
70
|
+
show_forecasted: bool = False,
|
71
|
+
show_closed: bool = False,
|
72
|
+
show_archived: bool = False) -> str:
|
73
|
+
|
74
|
+
self.logger.info(f"Querying by Assistance Listing Numbers: {assistance_listing_numbers}, query='{query_text}'")
|
75
|
+
|
76
|
+
if not assistance_listing_numbers:
|
77
|
+
return json.dumps({"error": "assistance_listing_numbers list cannot be empty.", "success": False})
|
78
|
+
# Basic pattern validation could be added here for each number if desired,
|
79
|
+
# but the API will ultimately validate.
|
80
|
+
|
81
|
+
specific_filters: Dict[str, Any] = {
|
82
|
+
"assistance_listing_number": {"one_of": assistance_listing_numbers}
|
83
|
+
}
|
84
|
+
|
85
|
+
specific_query_params: Optional[Dict[str, Any]] = None
|
86
|
+
if query_text:
|
87
|
+
specific_query_params = {"query": query_text}
|
88
|
+
|
89
|
+
payload = self._build_api_payload(
|
90
|
+
specific_query_params=specific_query_params,
|
91
|
+
specific_filters=specific_filters,
|
92
|
+
items_per_page=items_per_page,
|
93
|
+
page_number=page_number,
|
94
|
+
order_by=order_by,
|
95
|
+
sort_direction=sort_direction,
|
96
|
+
show_posted=show_posted,
|
97
|
+
show_forecasted=show_forecasted,
|
98
|
+
show_closed=show_closed,
|
99
|
+
show_archived=show_archived,
|
100
|
+
query_operator=query_operator
|
101
|
+
)
|
102
|
+
return self._execute_search(payload)
|
@@ -0,0 +1,117 @@
|
|
1
|
+
import logging
|
2
|
+
import json
|
3
|
+
from typing import Optional, Dict, Any
|
4
|
+
|
5
|
+
from .search_base import SearchOpportunitiesBase
|
6
|
+
from ..config import FunctionRegistry
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
@FunctionRegistry.register
|
11
|
+
class QueryByAwardCriteria(SearchOpportunitiesBase):
|
12
|
+
"""
|
13
|
+
Tool to search for grant opportunities based on award amount criteria.
|
14
|
+
"""
|
15
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, name: str = "query_opportunities_by_award_criteria"):
|
16
|
+
super().__init__(api_key=api_key, base_url=base_url, name=name)
|
17
|
+
self.name = name
|
18
|
+
|
19
|
+
@property
|
20
|
+
def definition(self):
|
21
|
+
base_params = self._get_common_parameter_definitions()
|
22
|
+
specific_params = {
|
23
|
+
"min_award_floor": {"type": "integer", "description": "Optional. Minimum award floor amount."},
|
24
|
+
"max_award_ceiling": {"type": "integer", "description": "Optional. Maximum award ceiling amount."},
|
25
|
+
"min_expected_awards": {"type": "integer", "description": "Optional. Minimum number of expected awards."},
|
26
|
+
"max_expected_awards": {"type": "integer", "description": "Optional. Maximum number of expected awards."},
|
27
|
+
"min_total_funding": {"type": "integer", "description": "Optional. Minimum estimated total program funding."},
|
28
|
+
"max_total_funding": {"type": "integer", "description": "Optional. Maximum estimated total program funding."},
|
29
|
+
"query_text": {
|
30
|
+
"type": "string",
|
31
|
+
"description": "Optional. Text to search for within the results filtered by award criteria."
|
32
|
+
},
|
33
|
+
"query_operator": {
|
34
|
+
"type": "string",
|
35
|
+
"enum": ["AND", "OR"],
|
36
|
+
"description": "Operator for 'query_text' if provided (default: AND).",
|
37
|
+
"default": "AND"
|
38
|
+
}
|
39
|
+
}
|
40
|
+
all_properties = {**specific_params, **base_params}
|
41
|
+
|
42
|
+
return {
|
43
|
+
"type": "function",
|
44
|
+
"function": {
|
45
|
+
"name": self.name,
|
46
|
+
"description": (
|
47
|
+
"Search for grant opportunities by award criteria (floor, ceiling, number of awards, total funding). "
|
48
|
+
"At least one award criterion must be specified. "
|
49
|
+
f"{self._pagination_description}"
|
50
|
+
f"{self._status_filter_description}"
|
51
|
+
f"{self._common_search_description_suffix}"
|
52
|
+
),
|
53
|
+
"parameters": {
|
54
|
+
"type": "object",
|
55
|
+
"properties": all_properties,
|
56
|
+
"required": [] # Logic in fn enforces one award criteria
|
57
|
+
}
|
58
|
+
}
|
59
|
+
}
|
60
|
+
|
61
|
+
def fn(self,
|
62
|
+
min_award_floor: Optional[int] = None, max_award_ceiling: Optional[int] = None,
|
63
|
+
min_expected_awards: Optional[int] = None, max_expected_awards: Optional[int] = None,
|
64
|
+
min_total_funding: Optional[int] = None, max_total_funding: Optional[int] = None,
|
65
|
+
query_text: Optional[str] = None, query_operator: str = "AND",
|
66
|
+
# Common params
|
67
|
+
items_per_page: int = 5, page_number: int = 1, order_by: str = "relevancy",
|
68
|
+
sort_direction: str = "descending", show_posted: bool = True, show_forecasted: bool = False,
|
69
|
+
show_closed: bool = False, show_archived: bool = False) -> str:
|
70
|
+
|
71
|
+
self.logger.info(f"Querying by award criteria: floor={min_award_floor}, ceiling={max_award_ceiling}, ... query='{query_text}'")
|
72
|
+
|
73
|
+
specific_filters: Dict[str, Any] = {}
|
74
|
+
award_criteria_provided = False
|
75
|
+
|
76
|
+
if min_award_floor is not None:
|
77
|
+
specific_filters.setdefault("award_floor", {})["min"] = min_award_floor
|
78
|
+
award_criteria_provided = True
|
79
|
+
if max_award_ceiling is not None:
|
80
|
+
specific_filters.setdefault("award_ceiling", {})["max"] = max_award_ceiling
|
81
|
+
award_criteria_provided = True
|
82
|
+
|
83
|
+
expected_awards_filter = {}
|
84
|
+
if min_expected_awards is not None:
|
85
|
+
expected_awards_filter["min"] = min_expected_awards
|
86
|
+
award_criteria_provided = True
|
87
|
+
if max_expected_awards is not None:
|
88
|
+
expected_awards_filter["max"] = max_expected_awards
|
89
|
+
award_criteria_provided = True
|
90
|
+
if expected_awards_filter:
|
91
|
+
specific_filters["expected_number_of_awards"] = expected_awards_filter
|
92
|
+
|
93
|
+
total_funding_filter = {}
|
94
|
+
if min_total_funding is not None:
|
95
|
+
total_funding_filter["min"] = min_total_funding
|
96
|
+
award_criteria_provided = True
|
97
|
+
if max_total_funding is not None:
|
98
|
+
total_funding_filter["max"] = max_total_funding
|
99
|
+
award_criteria_provided = True
|
100
|
+
if total_funding_filter:
|
101
|
+
specific_filters["estimated_total_program_funding"] = total_funding_filter
|
102
|
+
|
103
|
+
if not award_criteria_provided and not query_text:
|
104
|
+
return json.dumps({"error": "At least one award criterion or query_text must be specified.", "success": False})
|
105
|
+
|
106
|
+
specific_query_params: Optional[Dict[str, Any]] = None
|
107
|
+
if query_text:
|
108
|
+
specific_query_params = {"query": query_text}
|
109
|
+
|
110
|
+
payload = self._build_api_payload(
|
111
|
+
specific_query_params=specific_query_params,
|
112
|
+
specific_filters=specific_filters if specific_filters else None,
|
113
|
+
items_per_page=items_per_page, page_number=page_number, order_by=order_by,
|
114
|
+
sort_direction=sort_direction, show_posted=show_posted, show_forecasted=show_forecasted,
|
115
|
+
show_closed=show_closed, show_archived=show_archived, query_operator=query_operator
|
116
|
+
)
|
117
|
+
return self._execute_search(payload)
|