dhisana 0.0.1.dev243__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.
Files changed (102) hide show
  1. dhisana/__init__.py +1 -0
  2. dhisana/cli/__init__.py +1 -0
  3. dhisana/cli/cli.py +20 -0
  4. dhisana/cli/datasets.py +27 -0
  5. dhisana/cli/models.py +26 -0
  6. dhisana/cli/predictions.py +20 -0
  7. dhisana/schemas/__init__.py +1 -0
  8. dhisana/schemas/common.py +399 -0
  9. dhisana/schemas/sales.py +965 -0
  10. dhisana/ui/__init__.py +1 -0
  11. dhisana/ui/components.py +472 -0
  12. dhisana/utils/__init__.py +1 -0
  13. dhisana/utils/add_mapping.py +352 -0
  14. dhisana/utils/agent_tools.py +51 -0
  15. dhisana/utils/apollo_tools.py +1597 -0
  16. dhisana/utils/assistant_tool_tag.py +4 -0
  17. dhisana/utils/built_with_api_tools.py +282 -0
  18. dhisana/utils/cache_output_tools.py +98 -0
  19. dhisana/utils/cache_output_tools_local.py +78 -0
  20. dhisana/utils/check_email_validity_tools.py +717 -0
  21. dhisana/utils/check_for_intent_signal.py +107 -0
  22. dhisana/utils/check_linkedin_url_validity.py +209 -0
  23. dhisana/utils/clay_tools.py +43 -0
  24. dhisana/utils/clean_properties.py +135 -0
  25. dhisana/utils/company_utils.py +60 -0
  26. dhisana/utils/compose_salesnav_query.py +259 -0
  27. dhisana/utils/compose_search_query.py +759 -0
  28. dhisana/utils/compose_three_step_workflow.py +234 -0
  29. dhisana/utils/composite_tools.py +137 -0
  30. dhisana/utils/dataframe_tools.py +237 -0
  31. dhisana/utils/domain_parser.py +45 -0
  32. dhisana/utils/email_body_utils.py +72 -0
  33. dhisana/utils/email_parse_helpers.py +132 -0
  34. dhisana/utils/email_provider.py +375 -0
  35. dhisana/utils/enrich_lead_information.py +933 -0
  36. dhisana/utils/extract_email_content_for_llm.py +101 -0
  37. dhisana/utils/fetch_openai_config.py +129 -0
  38. dhisana/utils/field_validators.py +426 -0
  39. dhisana/utils/g2_tools.py +104 -0
  40. dhisana/utils/generate_content.py +41 -0
  41. dhisana/utils/generate_custom_message.py +271 -0
  42. dhisana/utils/generate_email.py +278 -0
  43. dhisana/utils/generate_email_response.py +465 -0
  44. dhisana/utils/generate_flow.py +102 -0
  45. dhisana/utils/generate_leads_salesnav.py +303 -0
  46. dhisana/utils/generate_linkedin_connect_message.py +224 -0
  47. dhisana/utils/generate_linkedin_response_message.py +317 -0
  48. dhisana/utils/generate_structured_output_internal.py +462 -0
  49. dhisana/utils/google_custom_search.py +267 -0
  50. dhisana/utils/google_oauth_tools.py +727 -0
  51. dhisana/utils/google_workspace_tools.py +1294 -0
  52. dhisana/utils/hubspot_clearbit.py +96 -0
  53. dhisana/utils/hubspot_crm_tools.py +2440 -0
  54. dhisana/utils/instantly_tools.py +149 -0
  55. dhisana/utils/linkedin_crawler.py +168 -0
  56. dhisana/utils/lusha_tools.py +333 -0
  57. dhisana/utils/mailgun_tools.py +156 -0
  58. dhisana/utils/mailreach_tools.py +123 -0
  59. dhisana/utils/microsoft365_tools.py +455 -0
  60. dhisana/utils/openai_assistant_and_file_utils.py +267 -0
  61. dhisana/utils/openai_helpers.py +977 -0
  62. dhisana/utils/openapi_spec_to_tools.py +45 -0
  63. dhisana/utils/openapi_tool/__init__.py +1 -0
  64. dhisana/utils/openapi_tool/api_models.py +633 -0
  65. dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
  66. dhisana/utils/openapi_tool/openapi_tool.py +319 -0
  67. dhisana/utils/parse_linkedin_messages_txt.py +100 -0
  68. dhisana/utils/profile.py +37 -0
  69. dhisana/utils/proxy_curl_tools.py +1226 -0
  70. dhisana/utils/proxycurl_search_leads.py +426 -0
  71. dhisana/utils/python_function_to_tools.py +83 -0
  72. dhisana/utils/research_lead.py +176 -0
  73. dhisana/utils/sales_navigator_crawler.py +1103 -0
  74. dhisana/utils/salesforce_crm_tools.py +477 -0
  75. dhisana/utils/search_router.py +131 -0
  76. dhisana/utils/search_router_jobs.py +51 -0
  77. dhisana/utils/sendgrid_tools.py +162 -0
  78. dhisana/utils/serarch_router_local_business.py +75 -0
  79. dhisana/utils/serpapi_additional_tools.py +290 -0
  80. dhisana/utils/serpapi_google_jobs.py +117 -0
  81. dhisana/utils/serpapi_google_search.py +188 -0
  82. dhisana/utils/serpapi_local_business_search.py +129 -0
  83. dhisana/utils/serpapi_search_tools.py +852 -0
  84. dhisana/utils/serperdev_google_jobs.py +125 -0
  85. dhisana/utils/serperdev_local_business.py +154 -0
  86. dhisana/utils/serperdev_search.py +233 -0
  87. dhisana/utils/smtp_email_tools.py +582 -0
  88. dhisana/utils/test_connect.py +2087 -0
  89. dhisana/utils/trasform_json.py +173 -0
  90. dhisana/utils/web_download_parse_tools.py +189 -0
  91. dhisana/utils/workflow_code_model.py +5 -0
  92. dhisana/utils/zoominfo_tools.py +357 -0
  93. dhisana/workflow/__init__.py +1 -0
  94. dhisana/workflow/agent.py +18 -0
  95. dhisana/workflow/flow.py +44 -0
  96. dhisana/workflow/task.py +43 -0
  97. dhisana/workflow/test.py +90 -0
  98. dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
  99. dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
  100. dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
  101. dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
  102. dhisana-0.0.1.dev243.dist-info/top_level.txt +1 -0
@@ -0,0 +1,162 @@
1
+ """Mail delivery helpers for SendGrid and compatibility exports for Mailgun.
2
+
3
+ This module now contains:
4
+ - SendGrid: helpers to send e-mail via SendGrid's REST API.
5
+ - Mailgun: re-exports for helpers that were moved to `mailgun_tools.py`.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from typing import Optional, List, Dict
11
+ from email.utils import parseaddr
12
+
13
+ import aiohttp
14
+
15
+ from dhisana.utils.assistant_tool_tag import assistant_tool
16
+ from dhisana.schemas.common import SendEmailContext
17
+ from dhisana.utils.email_body_utils import body_variants
18
+
19
+ # --------------------------------------------------------------------------- #
20
+ # Mailgun (re-exported from dedicated module for backward compatibility)
21
+ # --------------------------------------------------------------------------- #
22
+ from .mailgun_tools import (
23
+ get_mailgun_notify_key,
24
+ get_mailgun_notify_domain,
25
+ send_email_with_mailgun,
26
+ )
27
+
28
+
29
+ # --------------------------------------------------------------------------- #
30
+ # SendGrid helpers
31
+ # --------------------------------------------------------------------------- #
32
+
33
+ def get_sendgrid_api_key(tool_config: Optional[List[Dict]] = None) -> str:
34
+ """
35
+ Retrieve the SendGrid API key from tool_config or environment.
36
+
37
+ Looks for an integration named "sendgrid" and reads configuration item
38
+ with name "apiKey". Falls back to env var SENDGRID_API_KEY.
39
+ """
40
+ key: Optional[str] = None
41
+ if tool_config:
42
+ cfg = next((c for c in tool_config if c.get("name") == "sendgrid"), None)
43
+ if cfg:
44
+ cfg_map = {i.get("name"): i.get("value") for i in cfg.get("configuration", []) if i}
45
+ key = cfg_map.get("apiKey")
46
+ key = key or os.getenv("SENDGRID_API_KEY")
47
+ if not key:
48
+ raise ValueError(
49
+ "SendGrid integration is not configured. Please configure the connection to SendGrid in Integrations."
50
+ )
51
+ return key
52
+
53
+
54
+ @assistant_tool
55
+ async def send_email_with_sendgrid(
56
+ sender: str,
57
+ recipients: List[str],
58
+ subject: str,
59
+ message: str,
60
+ tool_config: Optional[List[Dict]] = None,
61
+ body_format: Optional[str] = None,
62
+ custom_headers: Optional[Dict[str, str]] = None,
63
+ ):
64
+ """
65
+ Send an email using SendGrid's v3 Mail Send API.
66
+
67
+ Parameters:
68
+ - sender: Either "Name <email@example.com>" or a plain e-mail address.
69
+ - recipients: List of recipient e-mail addresses.
70
+ - subject: Subject string.
71
+ - message: HTML body content.
72
+ - tool_config: Optional integration configuration list.
73
+ - custom_headers: Optional mapping of header names to values.
74
+ """
75
+ api_key = get_sendgrid_api_key(tool_config)
76
+
77
+ name, email_addr = parseaddr(sender)
78
+ from_obj: Dict[str, str] = {"email": email_addr or sender}
79
+ if name:
80
+ from_obj["name"] = name
81
+
82
+ to_list = [{"email": r} for r in recipients if r]
83
+ if not to_list:
84
+ return {"error": "No recipients provided"}
85
+
86
+ plain_body, html_body, _ = body_variants(message, body_format)
87
+ content = [
88
+ {"type": "text/plain", "value": plain_body},
89
+ {"type": "text/html", "value": html_body},
90
+ ]
91
+
92
+ payload = {
93
+ "personalizations": [
94
+ {
95
+ "to": to_list,
96
+ "subject": subject,
97
+ }
98
+ ],
99
+ "from": from_obj,
100
+ "content": content,
101
+ }
102
+
103
+ if custom_headers:
104
+ payload["headers"] = {
105
+ header: str(value)
106
+ for header, value in custom_headers.items()
107
+ if header and value is not None
108
+ }
109
+
110
+ headers = {
111
+ "Authorization": f"Bearer {api_key}",
112
+ "Content-Type": "application/json",
113
+ }
114
+
115
+ try:
116
+ async with aiohttp.ClientSession() as session:
117
+ async with session.post(
118
+ "https://api.sendgrid.com/v3/mail/send",
119
+ headers=headers,
120
+ json=payload,
121
+ ) as response:
122
+ # SendGrid returns 202 Accepted on success with empty body
123
+ if response.status == 202:
124
+ return {"status": 202, "message": "accepted"}
125
+ # On error, try to parse JSON for helpful message
126
+ try:
127
+ err = await response.json()
128
+ except Exception:
129
+ err = {"text": await response.text()}
130
+ return {"error": err, "status": response.status}
131
+ except Exception as ex:
132
+ logging.warning(f"Error sending email via SendGrid: {ex}")
133
+ return {"error": str(ex)}
134
+
135
+
136
+ async def send_email_using_sendgrid_async(
137
+ ctx: SendEmailContext,
138
+ tool_config: Optional[List[Dict]] = None,
139
+ ) -> str:
140
+ """
141
+ Provider-style wrapper for SendGrid using SendEmailContext.
142
+ Returns an opaque token since SendGrid does not return a message id.
143
+ """
144
+ plain_body, html_body, _ = body_variants(
145
+ ctx.body,
146
+ getattr(ctx, "body_format", None),
147
+ )
148
+ result = await send_email_with_sendgrid(
149
+ sender=f"{ctx.sender_name} <{ctx.sender_email}>",
150
+ recipients=[ctx.recipient],
151
+ subject=ctx.subject,
152
+ message=ctx.body or "",
153
+ body_format=getattr(ctx, "body_format", None),
154
+ tool_config=tool_config,
155
+ custom_headers=getattr(ctx, "headers", None),
156
+ )
157
+ # Normalise output to a string id-like value
158
+ if isinstance(result, dict) and result.get("status") == 202:
159
+ return f"sent:{ctx.sender_email}:{ctx.recipient}:{ctx.subject}"
160
+ if isinstance(result, dict) and "error" in result:
161
+ raise RuntimeError(f"SendGrid send failed: {result['error']}")
162
+ return str(result)
@@ -0,0 +1,75 @@
1
+ import json
2
+ import logging
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from dhisana.utils.assistant_tool_tag import assistant_tool
6
+
7
+ # Your provider-specific helpers (paths may differ in your repo)
8
+ from dhisana.utils.serperdev_local_business import search_local_business_serper
9
+ from dhisana.utils.serpapi_local_business_search import search_local_business_serpai
10
+
11
+ # Re-use your existing provider detector
12
+ from dhisana.utils.search_router import detect_search_provider # or copy the function shown earlier
13
+
14
+ logger = logging.getLogger(__name__)
15
+ logging.basicConfig(level=logging.INFO)
16
+
17
+
18
+ @assistant_tool
19
+ async def search_local_business_with_tools(
20
+ query: str,
21
+ number_of_results: int = 20,
22
+ offset: int = 0,
23
+ tool_config: Optional[List[Dict]] = None,
24
+ location: Optional[str] = None,
25
+ ) -> List[str]:
26
+ """
27
+ Router that returns local-business (Google Maps) results using whichever
28
+ provider is configured in `tool_config`.
29
+
30
+ Priority order:
31
+ 1. Serper.dev – uses `search_local_business_serper`
32
+ 2. SerpApi – uses `search_local_business_serpai`
33
+
34
+ Args:
35
+ query: Search string (e.g. "plumbers near Almaden Valley").
36
+ number_of_results: Desired row count (will paginate if > provider page size).
37
+ offset: Page offset (0-based; converted to provider-specific page or start).
38
+ tool_config: Dhisana tool-configuration blob listing available providers.
39
+ location: Optional city/region hint (Serper + SerpApi both accept it).
40
+
41
+ Returns:
42
+ List[str]: Each element is a JSON-encoded dict with keys:
43
+ full_name, organization_name, phone, organization_website,
44
+ rating, reviews, address, google_maps_url.
45
+ If no provider is configured, returns one item with an "error" key.
46
+ """
47
+ if not query:
48
+ logger.warning("Empty query received by local-business router.")
49
+ return []
50
+
51
+ provider = detect_search_provider(tool_config)
52
+ logger.debug("Local-business router chose provider: %s", provider)
53
+
54
+ if provider == "serperdev":
55
+ logger.info("Routing to Serper.dev local-business helper.")
56
+ return await search_local_business_serper(
57
+ query=query,
58
+ number_of_results=number_of_results,
59
+ offset=offset,
60
+ tool_config=tool_config,
61
+ location=location,
62
+ )
63
+
64
+ if provider == "serpapi":
65
+ logger.info("Routing to SerpApi local-business helper.")
66
+ return await search_local_business_serpai(
67
+ query=query,
68
+ number_of_results=number_of_results,
69
+ offset=offset,
70
+ tool_config=tool_config,
71
+ location=location,
72
+ )
73
+
74
+ logger.error("No supported local-business provider found in tool_config.")
75
+ return [json.dumps({"error": "No supported local-business provider configured."})]
@@ -0,0 +1,290 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from typing import Any, Dict, List, Optional
5
+ from urllib.parse import urlparse
6
+ import aiohttp
7
+ from bs4 import BeautifulSoup
8
+ import urllib
9
+
10
+ from dhisana.utils.assistant_tool_tag import assistant_tool
11
+ from dhisana.utils.cache_output_tools import cache_output, retrieve_output
12
+ from dhisana.utils.web_download_parse_tools import fetch_html_content
13
+ from dhisana.utils.search_router import search_google_with_tools
14
+
15
+ import logging
16
+ logging.basicConfig(level=logging.INFO)
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def get_serp_api_access_token(tool_config: Optional[List[Dict]] = None) -> str:
21
+ """
22
+ Retrieves the SERPAPI_KEY access token from the provided tool configuration.
23
+
24
+ Args:
25
+ tool_config (list): A list of dictionaries containing the tool configuration.
26
+ Each dictionary should have a "name" key and a "configuration" key,
27
+ where "configuration" is a list of dictionaries containing "name" and "value" keys.
28
+
29
+ Returns:
30
+ str: The SERPAPI_KEY access token.
31
+
32
+ Raises:
33
+ ValueError: If the SerpAPI integration has not been configured.
34
+ """
35
+ logger.info("Entering get_serp_api_access_token")
36
+ SERPAPI_KEY = None
37
+
38
+ if tool_config:
39
+ logger.debug(f"Tool config provided: {tool_config}")
40
+ serpapi_config = next(
41
+ (item for item in tool_config if item.get("name") == "serpapi"), None
42
+ )
43
+ if serpapi_config:
44
+ config_map = {
45
+ item["name"]: item["value"]
46
+ for item in serpapi_config.get("configuration", [])
47
+ if item
48
+ }
49
+ SERPAPI_KEY = config_map.get("apiKey")
50
+ else:
51
+ logger.warning("No 'serpapi' config item found in tool_config.")
52
+ else:
53
+ logger.debug("No tool_config provided or it's None.")
54
+
55
+ SERPAPI_KEY = SERPAPI_KEY or os.getenv("SERPAPI_KEY")
56
+ if not SERPAPI_KEY:
57
+ logger.error("SerpAPI integration is not configured.")
58
+ raise ValueError(
59
+ "SerpAPI integration is not configured. Please configure the connection to SerpAPI in Integrations."
60
+ )
61
+
62
+ logger.info("Retrieved SERPAPI_KEY successfully.")
63
+ return SERPAPI_KEY
64
+
65
+
66
+
67
+
68
+ @assistant_tool
69
+ async def search_google_maps(
70
+ query: str,
71
+ number_of_results: int = 3,
72
+ tool_config: Optional[List[Dict]] = None
73
+ ) -> List[str]:
74
+ """
75
+ Search Google Maps using SERP API and return the results as an array of serialized JSON strings.
76
+
77
+ Parameters:
78
+ - query (str): The search query.
79
+ - number_of_results (int): The number of results to return.
80
+ """
81
+ logger.info("Entering search_google_maps")
82
+ if not query:
83
+ logger.warning("Empty query string provided for search_google_maps.")
84
+ return []
85
+
86
+ SERPAPI_KEY = get_serp_api_access_token(tool_config)
87
+ params = {
88
+ "q": query,
89
+ "num": number_of_results,
90
+ "api_key": SERPAPI_KEY,
91
+ "engine": "google_maps"
92
+ }
93
+ url = "https://serpapi.com/search"
94
+
95
+ logger.debug(f"Searching Google Maps with params: {params}")
96
+ try:
97
+ async with aiohttp.ClientSession() as session:
98
+ async with session.get(url, params=params) as response:
99
+ logger.debug(f"Received status: {response.status}")
100
+ result = await response.json()
101
+ if response.status != 200:
102
+ logger.warning(f"Non-200 response from SERP API: {result}")
103
+ return [json.dumps({"error": result})]
104
+
105
+ serialized_results = [json.dumps(item) for item in result.get('local_results', [])]
106
+ logger.info(f"Returning {len(serialized_results)} map results.")
107
+ return serialized_results
108
+ except Exception as e:
109
+ logger.exception("Exception during search_google_maps request.")
110
+ return [json.dumps({"error": str(e)})]
111
+
112
+
113
+ @assistant_tool
114
+ async def search_google_news(
115
+ query: str,
116
+ number_of_results: int = 3,
117
+ tool_config: Optional[List[Dict]] = None
118
+ ) -> List[str]:
119
+ """
120
+ Search Google News using SERP API and return the results as an array of serialized JSON strings.
121
+
122
+ Parameters:
123
+ - query (str): The search query.
124
+ - number_of_results (int): The number of results to return.
125
+ """
126
+ logger.info("Entering search_google_news")
127
+ if not query:
128
+ logger.warning("Empty query string provided for search_google_news.")
129
+ return []
130
+
131
+ SERPAPI_KEY = get_serp_api_access_token(tool_config)
132
+ params = {
133
+ "q": query,
134
+ "num": number_of_results,
135
+ "api_key": SERPAPI_KEY,
136
+ "engine": "google_news"
137
+ }
138
+ url = "https://serpapi.com/search"
139
+
140
+ logger.debug(f"Searching Google News with params: {params}")
141
+ try:
142
+ async with aiohttp.ClientSession() as session:
143
+ async with session.get(url, params=params) as response:
144
+ logger.debug(f"Received status: {response.status}")
145
+ result = await response.json()
146
+ if response.status != 200:
147
+ logger.warning(f"Non-200 response from SERP API: {result}")
148
+ return [json.dumps({"error": result})]
149
+
150
+ serialized_results = [json.dumps(item) for item in result.get('news_results', [])]
151
+ logger.info(f"Returning {len(serialized_results)} news results.")
152
+ return serialized_results
153
+ except Exception as e:
154
+ logger.exception("Exception during search_google_news request.")
155
+ return [json.dumps({"error": str(e)})]
156
+
157
+
158
+ @assistant_tool
159
+ async def search_job_postings(
160
+ query: str,
161
+ number_of_results: int,
162
+ tool_config: Optional[List[Dict]] = None
163
+ ) -> List[str]:
164
+ """
165
+ Search for job postings using SERP API and return the results as an array of serialized JSON strings.
166
+
167
+ Parameters:
168
+ - query (str): The search query.
169
+ - number_of_results (int): The number of results to return.
170
+ """
171
+ logger.info("Entering search_job_postings")
172
+ if not query:
173
+ logger.warning("Empty query string provided for search_job_postings.")
174
+ return []
175
+
176
+ SERPAPI_KEY = get_serp_api_access_token(tool_config)
177
+ params = {
178
+ "q": query,
179
+ "num": number_of_results,
180
+ "api_key": SERPAPI_KEY,
181
+ "engine": "google_jobs"
182
+ }
183
+ url = "https://serpapi.com/search"
184
+
185
+ logger.debug(f"Searching Google Jobs with params: {params}")
186
+ try:
187
+ async with aiohttp.ClientSession() as session:
188
+ async with session.get(url, params=params) as response:
189
+ logger.debug(f"Received status: {response.status}")
190
+ result = await response.json()
191
+ if response.status != 200:
192
+ logger.warning(f"Non-200 response from SERP API: {result}")
193
+ return [json.dumps({"error": result})]
194
+
195
+ serialized_results = [json.dumps(item) for item in result.get('jobs_results', [])]
196
+ logger.info(f"Returning {len(serialized_results)} job posting results.")
197
+ return serialized_results
198
+ except Exception as e:
199
+ logger.exception("Exception during search_job_postings request.")
200
+ return [json.dumps({"error": str(e)})]
201
+
202
+
203
+ @assistant_tool
204
+ async def search_google_images(
205
+ query: str,
206
+ number_of_results: int,
207
+ tool_config: Optional[List[Dict]] = None
208
+ ) -> List[str]:
209
+ """
210
+ Search Google Images using SERP API and return the results as an array of serialized JSON strings.
211
+
212
+ Parameters:
213
+ - query (str): The search query.
214
+ - number_of_results (int): The number of results to return.
215
+ """
216
+ logger.info("Entering search_google_images")
217
+ if not query:
218
+ logger.warning("Empty query string provided for search_google_images.")
219
+ return []
220
+
221
+ SERPAPI_KEY = get_serp_api_access_token(tool_config)
222
+ params = {
223
+ "q": query,
224
+ "num": number_of_results,
225
+ "api_key": SERPAPI_KEY,
226
+ "engine": "google_images"
227
+ }
228
+ url = "https://serpapi.com/search"
229
+
230
+ logger.debug(f"Searching Google Images with params: {params}")
231
+ try:
232
+ async with aiohttp.ClientSession() as session:
233
+ async with session.get(url, params=params) as response:
234
+ logger.debug(f"Received status: {response.status}")
235
+ result = await response.json()
236
+ if response.status != 200:
237
+ logger.warning(f"Non-200 response from SERP API: {result}")
238
+ return [json.dumps({"error": result})]
239
+
240
+ serialized_results = [json.dumps(item) for item in result.get('images_results', [])]
241
+ logger.info(f"Returning {len(serialized_results)} image results.")
242
+ return serialized_results
243
+ except Exception as e:
244
+ logger.exception("Exception during search_google_images request.")
245
+ return [json.dumps({"error": str(e)})]
246
+
247
+
248
+ @assistant_tool
249
+ async def search_google_videos(
250
+ query: str,
251
+ number_of_results: int,
252
+ tool_config: Optional[List[Dict]] = None
253
+ ) -> List[str]:
254
+ """
255
+ Search Google Videos using SERP API and return the results as an array of serialized JSON strings.
256
+
257
+ Parameters:
258
+ - query (str): The search query.
259
+ - number_of_results (int): The number of results to return.
260
+ """
261
+ logger.info("Entering search_google_videos")
262
+ if not query:
263
+ logger.warning("Empty query string provided for search_google_videos.")
264
+ return []
265
+
266
+ SERPAPI_KEY = get_serp_api_access_token(tool_config)
267
+ params = {
268
+ "q": query,
269
+ "num": number_of_results,
270
+ "api_key": SERPAPI_KEY,
271
+ "engine": "google_videos"
272
+ }
273
+ url = "https://serpapi.com/search"
274
+
275
+ logger.debug(f"Searching Google Videos with params: {params}")
276
+ try:
277
+ async with aiohttp.ClientSession() as session:
278
+ async with session.get(url, params=params) as response:
279
+ logger.debug(f"Received status: {response.status}")
280
+ result = await response.json()
281
+ if response.status != 200:
282
+ logger.warning(f"Non-200 response from SERP API: {result}")
283
+ return [json.dumps({"error": result})]
284
+
285
+ serialized_results = [json.dumps(item) for item in result.get('video_results', [])]
286
+ logger.info(f"Returning {len(serialized_results)} video results.")
287
+ return serialized_results
288
+ except Exception as e:
289
+ logger.exception("Exception during search_google_videos request.")
290
+ return [json.dumps({"error": str(e)})]
@@ -0,0 +1,117 @@
1
+ import json
2
+ import logging
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ import aiohttp
6
+
7
+ from dhisana.utils.assistant_tool_tag import assistant_tool
8
+ from dhisana.utils.cache_output_tools import cache_output, retrieve_output
9
+ from dhisana.utils.serpapi_google_search import get_serp_api_access_token
10
+
11
+ logging.basicConfig(level=logging.INFO)
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _normalise_job_result(raw: Dict[str, Any]) -> Dict[str, Any]:
16
+ """Convert a SerpApi jobs result to a simplified schema."""
17
+ # ...existing code...
18
+ apply_link = ""
19
+ apply_options = raw.get("apply_options") or raw.get("apply_links") or []
20
+ if isinstance(apply_options, list) and apply_options:
21
+ first = apply_options[0]
22
+ if isinstance(first, dict):
23
+ apply_link = first.get("link") or first.get("apply_link") or ""
24
+ if isinstance(apply_options, dict):
25
+ apply_link = apply_options.get("link") or apply_options.get("apply_link") or ""
26
+
27
+ return {
28
+ "job_title": raw.get("title", ""),
29
+ "organization_name": raw.get("company_name") or raw.get("company", ""),
30
+ "location": raw.get("location", ""),
31
+ "via": raw.get("via", ""),
32
+ "description": raw.get("description", ""),
33
+ "job_posting_url": raw.get("job_highlight_url")
34
+ or raw.get("apply_link")
35
+ or apply_link
36
+ or raw.get("link", ""),
37
+ }
38
+
39
+
40
+ @assistant_tool
41
+ async def search_google_jobs_serpapi(
42
+ query: str,
43
+ number_of_results: int = 10,
44
+ offset: int = 0,
45
+ tool_config: Optional[List[Dict]] = None,
46
+ location: Optional[str] = None,
47
+ ) -> List[str]:
48
+ """Search Google Jobs via SerpApi and return normalised JSON strings."""
49
+ if not query:
50
+ logger.warning("Empty query provided to search_google_jobs_serpapi")
51
+ return []
52
+
53
+ cache_key = f"jobs_serpapi_{query}_{number_of_results}_{offset}_{location or ''}"
54
+ cached = retrieve_output("search_google_jobs_serpapi", cache_key)
55
+ if cached is not None:
56
+ return cached
57
+
58
+ api_key = get_serp_api_access_token(tool_config)
59
+ page_size = 10
60
+ collected: List[Dict[str, Any]] = []
61
+ next_page_token = None
62
+
63
+ async with aiohttp.ClientSession() as session:
64
+ while len(collected) < number_of_results:
65
+ to_fetch = min(page_size, number_of_results - len(collected))
66
+ params = {
67
+ "engine": "google_jobs",
68
+ "q": query,
69
+ "api_key": api_key,
70
+ "num": to_fetch,
71
+ }
72
+
73
+ if offset > 0 and next_page_token is None:
74
+ # If offset is provided but we don't have a token yet,
75
+ # we can't properly paginate. Log a warning.
76
+ logger.warning("Offset provided but next_page_token not available. Results may be incomplete.")
77
+
78
+ if next_page_token:
79
+ params["next_page_token"] = next_page_token
80
+
81
+ if location:
82
+ params["location"] = location
83
+
84
+ try:
85
+ async with session.get("https://serpapi.com/search", params=params) as resp:
86
+ if resp.status != 200:
87
+ try:
88
+ err = await resp.json()
89
+ except Exception:
90
+ err = await resp.text()
91
+ logger.warning("SerpApi jobs error: %s", err)
92
+ return [json.dumps({"error": err})]
93
+ payload = await resp.json()
94
+ except Exception as exc:
95
+ logger.exception("SerpApi jobs request failed")
96
+ return [json.dumps({"error": str(exc)})]
97
+
98
+ jobs = payload.get("jobs_results", [])
99
+ if not jobs:
100
+ break
101
+
102
+ collected.extend(jobs)
103
+
104
+ # Get the next_page_token for pagination
105
+ next_page_token = payload.get("serpapi_pagination", {}).get("next_page_token")
106
+ if not next_page_token:
107
+ break
108
+
109
+ # If we've collected enough results, stop pagination
110
+ if len(collected) >= number_of_results:
111
+ break
112
+
113
+ normalised = [_normalise_job_result(j) for j in collected[:number_of_results]]
114
+ serialised = [json.dumps(item) for item in normalised]
115
+ cache_output("search_google_jobs_serpapi", cache_key, serialised)
116
+ logger.info("Returned %d job results for '%s'", len(serialised), query)
117
+ return serialised