brainsqueeze commited on
Commit
1c6c5d7
·
verified ·
1 Parent(s): cdcc93b

First commit

Browse files
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Candid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,14 +1,13 @@
1
  ---
2
- title: Rfp Recommendations
3
- emoji: 😻
4
- colorFrom: indigo
5
- colorTo: indigo
 
6
  sdk: gradio
7
- sdk_version: 5.6.0
8
  app_file: app.py
9
- pinned: false
10
  license: mit
11
- short_description: Get recommendations for funding opportunities
12
  ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: RFP recommendations
3
+ short_description: Get recommendations for funding opportunities
4
+ emoji: 🤑
5
+ colorFrom: green
6
+ colorTo: blue
7
  sdk: gradio
8
+ sdk_version: 5.5.0
9
  app_file: app.py
10
+ pinned: true
11
  license: mit
 
12
  ---
13
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
__init__.py ADDED
File without changes
app.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import gradio as gr
4
+
5
+ from common import org_search_component as oss
6
+ from formatting import process_reasons, parse_pcs_descriptions, parse_geo_descriptions
7
+ from services import RfpRecommend
8
+
9
+ api = RfpRecommend()
10
+
11
+
12
+ def recommend_invoke(recipient: gr.State):
13
+ response = api(candid_entity_id=recipient[0])
14
+ output = []
15
+ for rfp in (response.get("recommendations", []) or []):
16
+ output.append([
17
+ rfp["funder_id"],
18
+ rfp["funder_name"],
19
+ rfp["funder_address"],
20
+ rfp["amount"],
21
+ (
22
+ f"<a href='{rfp['application_url']}' target='_blank' rel='noopener noreferrer'>"
23
+ f"{rfp['application_url']}</a>"
24
+ ),
25
+ rfp["deadline"],
26
+ rfp["description"],
27
+ parse_pcs_descriptions(rfp["taxonomy"]),
28
+ parse_geo_descriptions(rfp["area_served"])
29
+ ])
30
+ return (
31
+ output,
32
+ process_reasons(response.get("meta", {}) or {}),
33
+ response.get("recommendations", [])
34
+ )
35
+
36
+
37
+ def build_demo():
38
+ with gr.Blocks(theme=gr.themes.Soft(), title="RFP recommendations") as demo:
39
+ gr.Markdown(
40
+ """
41
+ <h1>RFP recommendations</h1>
42
+
43
+ <p>Receive recommendations for funding opportunities relevant to your work.</p>
44
+ <p>To get started lookup your nonprofit and then click **Get recommendations**.</p>
45
+ """
46
+ )
47
+
48
+ with gr.Row():
49
+ with gr.Column():
50
+ _, selected_org_state = oss.render()
51
+ with gr.Row():
52
+ recommend = gr.Button("Get recommendations", scale=5, variant="primary")
53
+ with gr.Row():
54
+ with gr.Accordion(label="Parameters used for recommendations", open=False):
55
+ reasons_output = gr.DataFrame(
56
+ col_count=3,
57
+ headers=["Reason category", "Reason value", "Reason description"],
58
+ interactive=False
59
+ )
60
+
61
+ rec_outputs = gr.DataFrame(
62
+ label="Recommended RFPs",
63
+ type="array",
64
+ headers=[
65
+ "Funder ID", "Name", "Address",
66
+ "Amount", "Application URL", "Deadline",
67
+ "Description", "About", "Where"
68
+ ],
69
+ col_count=(9, "fixed"),
70
+ datatype=[
71
+ "number", "str", "str",
72
+ "str", "markdown", "date",
73
+ "str", "markdown", "markdown"
74
+ ],
75
+ wrap=True,
76
+ max_height=1000,
77
+ column_widths=[
78
+ "5%", "10%", "20%",
79
+ "5", "15%", "5%",
80
+ "10%", "10%", "20%"
81
+ ],
82
+ interactive=False
83
+ )
84
+
85
+ with gr.Accordion("JSON output", open=False):
86
+ recommendations_json = gr.JSON(label="Recommended RFPs JSON")
87
+
88
+ # pylint: disable=no-member
89
+ recommend.click(
90
+ fn=recommend_invoke,
91
+ inputs=[selected_org_state],
92
+ outputs=[rec_outputs, reasons_output, recommendations_json]
93
+ )
94
+
95
+ return demo
96
+
97
+
98
+ if __name__ == '__main__':
99
+ app = build_demo()
100
+ app.queue(max_size=5).launch(
101
+ show_api=False,
102
+ auth=[
103
+ (os.getenv("APP_USERNAME"), os.getenv("APP_PASSWORD")),
104
+ (os.getenv("APP_PUBLIC_USERNAME"), os.getenv("APP_PUBLIC_PASSWORD")),
105
+ ],
106
+ auth_message="Login to Candid's letter of intent demo",
107
+ ssr_mode=False
108
+ )
common/__init__.py ADDED
File without changes
common/base/__init__.py ADDED
File without changes
common/base/api_base.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Optional, Any
2
+
3
+ from urllib3.util.retry import Retry
4
+ from requests.adapters import HTTPAdapter
5
+ import requests
6
+
7
+
8
+ class BaseAPI:
9
+
10
+ def __init__(
11
+ self,
12
+ url: str,
13
+ headers: Optional[Dict[str, Any]] = None,
14
+ total_retries: int = 3,
15
+ backoff_factor: int = 2
16
+ ) -> None:
17
+ total_retries = max(total_retries, 10)
18
+
19
+ adapter = HTTPAdapter(
20
+ max_retries=Retry(
21
+ total=total_retries,
22
+ status_forcelist=[429, 500, 502, 503, 504],
23
+ allowed_methods=frozenset({"HEAD", "GET", "POST", "OPTIONS"}),
24
+ backoff_factor=backoff_factor,
25
+ )
26
+ )
27
+ self.session = requests.Session()
28
+ self.session.mount("https://", adapter)
29
+ self.session.mount("http://", adapter)
30
+
31
+ self.__url = url
32
+ self.__headers = headers
33
+
34
+ def get(self, **request_kwargs):
35
+ r = self.session.get(url=self.__url, headers=self.__headers, params=request_kwargs, timeout=30)
36
+ r.raise_for_status()
37
+ return r.json()
38
+
39
+ def post(self, payload: Dict[str, Any]):
40
+ r = self.session.post(url=self.__url, headers=self.__headers, json=payload, timeout=30)
41
+ r.raise_for_status()
42
+ return r.json()
common/base/api_base_async.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Optional, Any
2
+ import json
3
+
4
+ import aiohttp
5
+
6
+
7
+ class BaseAsyncAPI:
8
+
9
+ def __init__(self, url: str, headers: Optional[Dict[str, Any]] = None, retries: int = 3) -> None:
10
+ self.__url = url
11
+ self.__headers = headers
12
+ self.__retries = max(retries, 5)
13
+
14
+ async def get(self, **request_kwargs):
15
+ session_timeout = aiohttp.ClientTimeout(total=30)
16
+ async with aiohttp.ClientSession(headers=self.__headers, timeout=session_timeout) as session:
17
+ output = {}
18
+ count = 1
19
+ while True:
20
+ if count >= self.__retries:
21
+ break
22
+ async with session.get(url=self.__url, params=request_kwargs) as r:
23
+ if r.status in {429, 500, 502, 503, 504}:
24
+ count += 1
25
+ elif r.status == 200:
26
+ output = await r.json()
27
+ break
28
+ else:
29
+ break
30
+ return output
31
+
32
+ async def post(self, payload: Dict[str, Any]):
33
+ session_timeout = aiohttp.ClientTimeout(total=30)
34
+ async with aiohttp.ClientSession(headers=self.__headers, timeout=session_timeout) as session:
35
+ output = {}
36
+ count = 1
37
+ while True:
38
+ if count >= self.__retries:
39
+ break
40
+ async with session.post(url=self.__url, data=json.dumps(payload).encode('utf8')) as r:
41
+ if r.status in {429, 500, 502, 503, 504}:
42
+ count += 1
43
+ elif r.status == 200:
44
+ output = await r.json()
45
+ break
46
+ else:
47
+ break
48
+ return output
common/base/utils.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+
4
+ def async_tasks(*tasks):
5
+
6
+ async def gather(*t):
7
+ t = [await _ for _ in t]
8
+ return await asyncio.gather(*t)
9
+
10
+ loop = asyncio.new_event_loop()
11
+ results = loop.run_until_complete(gather(*tasks))
12
+ loop.stop()
13
+ loop.close()
14
+ return results
common/org_search_component.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Tuple, Optional, Any
2
+ import os
3
+
4
+ import gradio as gr
5
+
6
+ try:
7
+ from base.api_base import BaseAPI
8
+ except ImportError:
9
+ from .base.api_base import BaseAPI
10
+
11
+
12
+ class OrgSearch(BaseAPI):
13
+
14
+ def __init__(self):
15
+ super().__init__(
16
+ url=f"{os.getenv('CDS_API_URL')}/v1/organization/search",
17
+ headers={"x-api-key": os.getenv('CDS_API_KEY')}
18
+ )
19
+
20
+ def __call__(self, name: str, **kwargs):
21
+ is_valid = False
22
+
23
+ payload = {
24
+ "names": [{
25
+ "value": name,
26
+ "type": "main"
27
+ }],
28
+ "status": "authorized"
29
+ }
30
+
31
+ if kwargs.get("ein"):
32
+ ein = kwargs.get("ein")
33
+ if "-" not in ein:
34
+ ein = f"{ein[:2]}-{ein[2:]}"
35
+ payload["ids"] = [{
36
+ "value": ein,
37
+ "type": "ein"
38
+ }]
39
+ is_valid = True
40
+
41
+ if kwargs.get("street") or kwargs.get("city") or kwargs.get("state") or kwargs.get("postal_code"):
42
+ payload["addresses"] = [{
43
+ "street1": kwargs.get("street") or "",
44
+ "city": kwargs.get("city") or "",
45
+ "state": kwargs.get("state") or "",
46
+ "postal_code": kwargs.get("postal_code") or ""
47
+ }]
48
+ is_valid = True
49
+
50
+ if not is_valid:
51
+ return None
52
+
53
+ result = self.post(payload=payload)
54
+ return result.get("payload", [])
55
+
56
+
57
+ search_org = OrgSearch()
58
+
59
+
60
+ def callback_to_state(event: gr.SelectData, state: gr.State) -> Tuple[List[Any], int]:
61
+ """Handles DataFrame `select` events. Updates the internal state for either the recipient or funder based on
62
+ selection. Also sends along the selected Candid entity ID to the proposal generation tab.
63
+
64
+ Parameters
65
+ ----------
66
+ event : gr.SelectData
67
+ state : gr.State
68
+
69
+ Returns
70
+ -------
71
+ Tuple[List[Any], int]
72
+ (Updated state, Candid entity ID)
73
+ """
74
+
75
+ row, _ = event.index
76
+
77
+ if len(state) == 0:
78
+ return [], None
79
+
80
+ # the state should be a nested list of lists
81
+ # if the state is a single list with non-list elements then we just want a pass-through
82
+ if all(isinstance(s, list) for s in state):
83
+ return state[row], state[row][0]
84
+ return state, state[0]
85
+
86
+
87
+ def lookup_organization(
88
+ name: str,
89
+ ein: Optional[str] = None,
90
+ # street: Optional[str] = None,
91
+ city: Optional[str] = None,
92
+ state: Optional[str] = None,
93
+ postal: Optional[str] = None,
94
+ ) -> Tuple[List[List[str]], List[List[str]]]:
95
+ """Performs a simple search using the CDS organization search API. Results are sent to the DataFrame table and also
96
+ populate the state for the recipient information.
97
+
98
+ Parameters
99
+ ----------
100
+ name : str
101
+ Org name
102
+ ein : Optional[str], optional
103
+ Org EIN, by default None
104
+ street : Optional[str], optional
105
+ Street address, by default None
106
+ city : Optional[str], optional
107
+ Address city, by default None
108
+ state : Optional[str], optional
109
+ Address state, by default None
110
+ postal : Optional[str], optional
111
+ Address postal code, by default None
112
+
113
+ Returns
114
+ -------
115
+ Tuple[List[List[str]], List[List[str]]]
116
+ (recip data, recip data)
117
+
118
+ Raises
119
+ ------
120
+ gr.Error
121
+ Raised if not enough information was entered to run a search
122
+ gr.Error
123
+ Raised if no search results were returned
124
+ """
125
+
126
+ results = search_org(name=name, ein=ein, city=city, state=state, postal=postal)
127
+ if results is None:
128
+ raise gr.Error("You must provide a name, and either an EIN or an address.")
129
+ if not results:
130
+ raise gr.Error("No organizations could be found. Please refine the search criteria.")
131
+
132
+ data = []
133
+ for applicant_data in results:
134
+ address = applicant_data.get("addresses", [{}])[0].get("normalized")
135
+ seal = (applicant_data.get("current_seal", {}) or {}).get("image")
136
+
137
+ record = [
138
+ applicant_data.get('candid_entity_id'),
139
+ applicant_data.get('main_sort_name'),
140
+ address
141
+ ]
142
+
143
+ if seal:
144
+ record.append(f"![](file={seal})")
145
+ else:
146
+ record.append("")
147
+
148
+ data.append(record)
149
+ return data, data
150
+
151
+
152
+ def render(org_id_element: Optional[gr.Blocks] = None) -> Tuple[gr.Blocks, gr.State]:
153
+ """Main blocks build and render function.
154
+
155
+ Parameters
156
+ ----------
157
+ org_id_element : Optional[gr.Blocks], optional
158
+ Callback Gradio element, by default None
159
+
160
+ Returns
161
+ -------
162
+ Tuple[gr.Blocks, gr.State]
163
+ (component, selected org state)
164
+ """
165
+
166
+ with gr.Blocks() as component:
167
+ org_data = gr.State([])
168
+ selected_org_data = gr.State([])
169
+
170
+ with gr.Row():
171
+ with gr.Column(scale=2):
172
+ name = gr.Textbox(label="Name of organization", lines=1)
173
+ ein = gr.Textbox(label="EIN of organization", lines=1)
174
+ with gr.Column(scale=3):
175
+ with gr.Group():
176
+ with gr.Row():
177
+ with gr.Column():
178
+ # street = gr.Textbox(label="Street address", lines=1)
179
+ city = gr.Textbox(label="City", lines=1)
180
+ with gr.Column():
181
+ state = gr.Textbox(label="State/province", lines=1)
182
+ postal = gr.Textbox(label="Postal code", lines=1)
183
+
184
+ search_button = gr.Button("Find organization", variant="primary")
185
+ org_info = gr.DataFrame(
186
+ label="Organizations",
187
+ type="array",
188
+ headers=["Candid ID", "Name", "Address", "Seal"],
189
+ col_count=(4, "fixed"),
190
+ datatype=["number", "str", "str", "markdown"],
191
+ wrap=True,
192
+ column_widths=["20%", "30%", "30%", "20%"]
193
+ )
194
+
195
+ if org_id_element is None:
196
+ org_id_element = gr.Textbox(label="Selected Candid entity ID", lines=1)
197
+
198
+ # pylint: disable=no-member
199
+ search_button.click(
200
+ fn=lambda name, ein, city, state, postal: lookup_organization(
201
+ name=name,
202
+ ein=ein,
203
+ # street=street,
204
+ city=city,
205
+ state=state,
206
+ postal=postal
207
+ ),
208
+ # inputs=[name, ein, street, city, state, postal],
209
+ inputs=[name, ein, city, state, postal],
210
+ outputs=[org_info, org_data],
211
+ api_name=False,
212
+ show_api=False
213
+ )
214
+
215
+ # pylint: disable=no-member
216
+ org_info.select(
217
+ fn=callback_to_state,
218
+ inputs=org_data,
219
+ outputs=[selected_org_data, org_id_element],
220
+ api_name=False,
221
+ show_api=False
222
+ )
223
+ return component, selected_org_data
formatting.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Tuple, Dict, Any
2
+
3
+
4
+ def process_reasons(reasons: dict) -> List[Tuple[str, str, str]]:
5
+ """Proprocessing for recommendation reasons data to display in a dataframe.
6
+
7
+ Parameters
8
+ ----------
9
+ reasons : dict
10
+
11
+ Returns
12
+ -------
13
+ List[Tuple[str, str, str]]
14
+ (attribute type, attribute code, attribute description)
15
+ """
16
+
17
+ output = []
18
+ for code, desc in (reasons.get("subject_codes", {}) or {}).items():
19
+ output.append(["PCS subject", code, desc])
20
+
21
+ for code, desc in (reasons.get("pop_group_codes", {}) or {}).items():
22
+ output.append(["PCS population", code, desc])
23
+
24
+ for code, desc in (reasons.get("geo_ids", {}) or {}).items():
25
+ output.append(["Geography served", code, desc])
26
+ return output
27
+
28
+
29
+ def parse_pcs_descriptions(taxonomy: List[Dict[str, Any]]) -> str:
30
+ reasons = []
31
+
32
+ for term in taxonomy:
33
+ if term.get("facet") == "subject":
34
+ reasons.append(f"- For <b>{term.get('description')}</b>")
35
+ elif term.get("facet") == "population":
36
+ reasons.append(f"- Serving <b>{term.get('description')}</b>")
37
+ return '\n'.join(reasons)
38
+
39
+
40
+ def parse_geo_descriptions(geos: List[Dict[str, Any]]) -> str:
41
+ reasons = []
42
+
43
+ for geo in geos:
44
+ reasons.append(f"- Serving <b>{geo['name']}</b>")
45
+ return '\n'.join(reasons)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ aiohttp
2
+ gradio
3
+ requests
4
+ urllib3
services.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from common.base.api_base_async import BaseAsyncAPI
4
+ from common.base.api_base import BaseAPI
5
+
6
+
7
+ class RfpRecommend(BaseAPI):
8
+
9
+ def __init__(self):
10
+ super().__init__(
11
+ url=os.getenv("RFP_RECOMMENDATION_URL"),
12
+ headers={"x-api-key": os.getenv("RFP_RECOMMENDATION_API_KEY")},
13
+ total_retries=5,
14
+ backoff_factor=10
15
+ )
16
+
17
+ def __call__(self, candid_entity_id: int):
18
+ results = self.get(candid_entity_id=candid_entity_id)
19
+ return results
20
+
21
+
22
+ class Inferencing(BaseAsyncAPI):
23
+
24
+ def __init__(self):
25
+ super().__init__(
26
+ url=os.getenv("AUTOCODING_API_URL"),
27
+ headers={"x-api-key": os.getenv("AUTOCODING_API_KEY")}
28
+ )
29
+
30
+ async def __call__(self, text: str, taxonomy: str = "pcs",
31
+ chunk: bool = False, high_precision: bool = False
32
+ ):
33
+ result = self.post(payload={
34
+ "text": text,
35
+ "taxonomy": taxonomy,
36
+ "chunk": chunk,
37
+ "high_precision": high_precision
38
+ })
39
+ return result
40
+
41
+
42
+ class Entities(BaseAsyncAPI):
43
+
44
+ def __init__(self):
45
+ super().__init__(
46
+ url=os.getenv("DOCUMENT_API_URL").replace("/analyze", "/entities"),
47
+ headers={"x-api-key": os.getenv("DOCUMENT_API_KEY")}
48
+ )
49
+
50
+ async def __call__(self, text: str):
51
+ result = self.post(payload={"text": text})
52
+ return result