finta-aurora-mcp 1.0.0__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.
- finta_aurora_mcp/__init__.py +3 -0
- finta_aurora_mcp/auth.py +105 -0
- finta_aurora_mcp/fix_org_info.py +44 -0
- finta_aurora_mcp/mcp.py +498 -0
- finta_aurora_mcp-1.0.0.dist-info/METADATA +216 -0
- finta_aurora_mcp-1.0.0.dist-info/RECORD +19 -0
- finta_aurora_mcp-1.0.0.dist-info/WHEEL +5 -0
- finta_aurora_mcp-1.0.0.dist-info/top_level.txt +2 -0
- tools/__init__.py +23 -0
- tools/addInvestorToTrackerTool.py +114 -0
- tools/contactSupportTool.py +7 -0
- tools/editInvestorTool.py +653 -0
- tools/getInvestorTool.py +109 -0
- tools/imageTool.py +77 -0
- tools/pdfScraperTool.py +31 -0
- tools/pineconeKnowledgeTool.py +35 -0
- tools/pineconeResourceTool.py +27 -0
- tools/serpAPITool.py +16 -0
- tools/webScraperTool.py +30 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
from langchain_core.tools import StructuredTool, ToolException
|
|
2
|
+
import requests
|
|
3
|
+
from pydantic.v1 import BaseModel, Field, ValidationError
|
|
4
|
+
from typing import Optional, List, Dict, Any
|
|
5
|
+
import os
|
|
6
|
+
from langchain_core.runnables.config import RunnableConfig
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
API_BASE_URL = os.environ.get('API_BASE_URL', '')
|
|
12
|
+
# API_BASE_URL = "https://us-central1-equity-token-1.cloudfunctions.net/"
|
|
13
|
+
|
|
14
|
+
class EditTargetInvestorInput(BaseModel):
|
|
15
|
+
id: Optional[str] = Field(default="", description="Target investor document ID. If not provided, use investorName or investorEmail to search for the investor first.")
|
|
16
|
+
investorName: Optional[str] = Field(default="", description="Investor's full name (e.g., 'John Smith') or first/last name. Use this if you don't have the investor ID. The tool will search for the investor by name.")
|
|
17
|
+
investorEmail: Optional[str] = Field(default="", description="Investor's email address. Use this if you don't have the investor ID. The tool will search for the investor by email.")
|
|
18
|
+
firstName: Optional[str] = Field(default="", description="Investor first name")
|
|
19
|
+
lastName: Optional[str] = Field(default="", description="Investor last name")
|
|
20
|
+
email: Optional[str] = Field(default="", description="Investor email")
|
|
21
|
+
phone: Optional[str] = Field(default="", description="Phone")
|
|
22
|
+
location: Optional[str] = Field(default="", description="Location")
|
|
23
|
+
status: Optional[str] = Field(default="", description="Tracker status (target, emailed, viewed, interested, diligence, committed, invested, passed)")
|
|
24
|
+
grade: Optional[str] = Field(default="", description="Investor grade (e.g., 'A', 'B', 'C'). This is DIFFERENT from tags - grade is a single rating value, not a tag.")
|
|
25
|
+
amount: Optional[str] = Field(default="", description="Amount (string or number)")
|
|
26
|
+
paymentMethod: Optional[str] = Field(default="", description="Transfer method")
|
|
27
|
+
dealId: Optional[str] = Field(default="", description="Deal document ID. If not provided, use dealName to search for the deal first.")
|
|
28
|
+
dealUuid: Optional[str] = Field(default="", description="Deal UUID")
|
|
29
|
+
dealName: Optional[str] = Field(default="", description="Deal name, round, or nickname (e.g., 'Seed', 'Series B', 'Custom Terms', or deal nickname). Use this if you don't have the deal ID. The tool will search for the deal by matching against the full deal title (round, name, and nickname), same as the deal selector in Aurora.")
|
|
30
|
+
collectionType: Optional[str] = Field(default="target_investors", description="Collection type: 'target_investors' (manually added investors) or 'investor_tracker' (contacts created when viewing deals). Defaults to 'target_investors'. Use 'investor_tracker' when updating contacts that viewed a deal.")
|
|
31
|
+
potentialIntroductionName: Optional[str] = Field(default="", description="Introduction Path Name (legacy field - will be auto-converted to introductionPaths). Use this when user says 'add [name] as introduction pathway' or 'add [name] as warm intro'. Example: 'Megan'")
|
|
32
|
+
potentialIntroduction: Optional[str] = Field(default="", description="Introduction Path Email or Name (legacy field - will be auto-converted to introductionPaths). Can be either an email address or a name. If email is provided separately in notes, this can be the name. Example: 'Megan' or 'megan@example.com'")
|
|
33
|
+
potentialIntroductionLinkedin: Optional[str] = Field(default="", description="Introduction Path LinkedIn URL (legacy field - will be auto-converted to introductionPaths)")
|
|
34
|
+
introductionPaths: Optional[List[Dict[str, Any]]] = Field(default=None, description="List of introduction/connection paths, each with name, email, linkedin, and optional primary flag. These are people who can introduce you to this investor. PREFERRED method for adding introductions. Only set primary: true if user explicitly says 'primary' or 'main' introduction. Example: [{'name': 'Megan', 'email': 'megan@example.com', 'linkedin': '...', 'primary': false}]")
|
|
35
|
+
setAsPrimaryIntroduction: Optional[bool] = Field(default=None, description="Set to true if the user wants this introduction pathway to be the primary one. Only set if user explicitly says 'primary' or 'main' introduction.")
|
|
36
|
+
noConnections: Optional[bool] = Field(default=None, description="Set to true if you don't have any mutual connections to this investor")
|
|
37
|
+
notes: Optional[str] = Field(default="", description="Notes")
|
|
38
|
+
feedback: Optional[str] = Field(default="", description="Feedback")
|
|
39
|
+
sourceUserId: Optional[str] = Field(default="", description="Source user ID - who on the team owns the relationship with the contact")
|
|
40
|
+
type: Optional[str] = Field(default="", description="Type of investor")
|
|
41
|
+
title: Optional[str] = Field(default="", description="Title")
|
|
42
|
+
organizationName: Optional[str] = Field(default="", description="Organization")
|
|
43
|
+
organizationWebsite: Optional[str] = Field(default="", description="Organization website")
|
|
44
|
+
linkedIn: Optional[str] = Field(default="", description="LinkedIn")
|
|
45
|
+
twitter: Optional[str] = Field(default="", description="Twitter")
|
|
46
|
+
crunchbase: Optional[str] = Field(default="", description="Crunchbase")
|
|
47
|
+
angelList: Optional[str] = Field(default="", description="AngelList")
|
|
48
|
+
investmentRangeFrom: Optional[str] = Field(default="", description="Investment range min")
|
|
49
|
+
investmentRangeTo: Optional[str] = Field(default="", description="Investment range max")
|
|
50
|
+
investmentSweetSpot: Optional[str] = Field(default="", description="Investment sweet spot")
|
|
51
|
+
fundSize: Optional[str] = Field(default="", description="Fund size")
|
|
52
|
+
leadsInvestments: Optional[bool] = Field(default=None, description="Leads investments")
|
|
53
|
+
preferredAssetClass: Optional[str] = Field(default="", description="Preferred asset class")
|
|
54
|
+
focusStages: Optional[List[str]] = Field(default=None, description="Focus stages")
|
|
55
|
+
prefferedCompanyTraction: Optional[str] = Field(default="", description="Preferred company traction")
|
|
56
|
+
industriesOfInterest: Optional[List[str]] = Field(default=None, description="Industries of interest")
|
|
57
|
+
industriesNotInterestedIn: Optional[List[str]] = Field(default=None, description="Industries not interested in")
|
|
58
|
+
preferredGeographicInvestmentFocus: Optional[str] = Field(default="", description="Geographic focus")
|
|
59
|
+
gender: Optional[str] = Field(default="", description="Gender")
|
|
60
|
+
bio: Optional[str] = Field(default="", description="Bio")
|
|
61
|
+
relevantPortfolioCompanies: Optional[str] = Field(default="", description="Relevant portfolio companies")
|
|
62
|
+
tags: Optional[List[str]] = Field(default=None, description="List of tags to add or replace. Tags are labels/categories like 'Series A', 'Seed', 'Super Angel', 'Big ups'. If provided, replaces all existing tags. Use this when user says 'tag [name] as [tag]' or 'add tag [tag] to [name]'. Example: ['Super Angel', 'Series A']. IMPORTANT: This is DIFFERENT from 'grade' - tags are multiple labels, grade is a single rating.")
|
|
63
|
+
|
|
64
|
+
def call_edit_target_investor_any_fields(
|
|
65
|
+
config: RunnableConfig,
|
|
66
|
+
id: Optional[str] = "",
|
|
67
|
+
investorName: Optional[str] = "",
|
|
68
|
+
investorEmail: Optional[str] = "",
|
|
69
|
+
firstName: Optional[str] = "",
|
|
70
|
+
lastName: Optional[str] = "",
|
|
71
|
+
email: Optional[str] = "",
|
|
72
|
+
phone: Optional[str] = "",
|
|
73
|
+
location: Optional[str] = "",
|
|
74
|
+
status: Optional[str] = "",
|
|
75
|
+
grade: Optional[str] = "",
|
|
76
|
+
amount: Optional[str] = "",
|
|
77
|
+
paymentMethod: Optional[str] = "",
|
|
78
|
+
dealId: Optional[str] = "",
|
|
79
|
+
dealUuid: Optional[str] = "",
|
|
80
|
+
dealName: Optional[str] = "",
|
|
81
|
+
collectionType: Optional[str] = "target_investors",
|
|
82
|
+
potentialIntroductionName: Optional[str] = "",
|
|
83
|
+
potentialIntroduction: Optional[str] = "",
|
|
84
|
+
potentialIntroductionLinkedin: Optional[str] = "",
|
|
85
|
+
introductionPaths: Optional[List[Dict[str, Any]]] = None,
|
|
86
|
+
setAsPrimaryIntroduction: Optional[bool] = None,
|
|
87
|
+
noConnections: Optional[bool] = None,
|
|
88
|
+
notes: Optional[str] = "",
|
|
89
|
+
feedback: Optional[str] = "",
|
|
90
|
+
sourceUserId: Optional[str] = "",
|
|
91
|
+
type: Optional[str] = "",
|
|
92
|
+
title: Optional[str] = "",
|
|
93
|
+
organizationName: Optional[str] = "",
|
|
94
|
+
organizationWebsite: Optional[str] = "",
|
|
95
|
+
linkedIn: Optional[str] = "",
|
|
96
|
+
twitter: Optional[str] = "",
|
|
97
|
+
crunchbase: Optional[str] = "",
|
|
98
|
+
angelList: Optional[str] = "",
|
|
99
|
+
investmentRangeFrom: Optional[str] = "",
|
|
100
|
+
investmentRangeTo: Optional[str] = "",
|
|
101
|
+
investmentSweetSpot: Optional[str] = "",
|
|
102
|
+
fundSize: Optional[str] = "",
|
|
103
|
+
leadsInvestments: Optional[bool] = None,
|
|
104
|
+
preferredAssetClass: Optional[str] = "",
|
|
105
|
+
focusStages: Optional[List[str]] = None,
|
|
106
|
+
prefferedCompanyTraction: Optional[str] = "",
|
|
107
|
+
industriesOfInterest: Optional[List[str]] = None,
|
|
108
|
+
industriesNotInterestedIn: Optional[List[str]] = None,
|
|
109
|
+
preferredGeographicInvestmentFocus: Optional[str] = "",
|
|
110
|
+
gender: Optional[str] = "",
|
|
111
|
+
bio: Optional[str] = "",
|
|
112
|
+
relevantPortfolioCompanies: Optional[str] = "",
|
|
113
|
+
tags: Optional[List[str]] = None,
|
|
114
|
+
):
|
|
115
|
+
"""
|
|
116
|
+
Update any fields on a target investor document, including nested investor fields.
|
|
117
|
+
Only provided fields are updated.
|
|
118
|
+
If investor ID is not provided, the tool will search for the investor by name or email first.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
org_info = config.get('configurable', {}).get("org_info", {})
|
|
122
|
+
handle = org_info.get("handle")
|
|
123
|
+
sourceUserId = org_info.get("founderId")
|
|
124
|
+
|
|
125
|
+
if not handle:
|
|
126
|
+
raise ToolException("Trouble with that request - organization handle not found in config")
|
|
127
|
+
if not sourceUserId:
|
|
128
|
+
raise ToolException("Trouble with that request - founder ID not found in config")
|
|
129
|
+
|
|
130
|
+
# Track if we had multiple matches (for user notification)
|
|
131
|
+
multiple_matches_info = None
|
|
132
|
+
|
|
133
|
+
# Determine which collection to use
|
|
134
|
+
use_tracker = collectionType and collectionType.lower() == "investor_tracker"
|
|
135
|
+
|
|
136
|
+
# If ID not provided, search for investor by name or email
|
|
137
|
+
if not id or id == "":
|
|
138
|
+
if not investorName and not investorEmail:
|
|
139
|
+
raise ToolException("Trouble with that request - either id, investorName, or investorEmail must be provided")
|
|
140
|
+
|
|
141
|
+
# If collectionType not specified, search both collections (target_investors first, then tracker)
|
|
142
|
+
found_investor = False
|
|
143
|
+
search_result = None
|
|
144
|
+
|
|
145
|
+
# First, try target_investors (unless explicitly told to use tracker)
|
|
146
|
+
if not use_tracker:
|
|
147
|
+
try:
|
|
148
|
+
search_url = API_BASE_URL + "fintaAI/tools/search-investor"
|
|
149
|
+
search_params = {
|
|
150
|
+
"organizationHandle": handle,
|
|
151
|
+
}
|
|
152
|
+
if investorEmail:
|
|
153
|
+
search_params["email"] = investorEmail
|
|
154
|
+
elif investorName:
|
|
155
|
+
search_params["name"] = investorName
|
|
156
|
+
|
|
157
|
+
search_response = requests.get(search_url, params=search_params, timeout=10)
|
|
158
|
+
search_response.raise_for_status()
|
|
159
|
+
search_result = search_response.json()
|
|
160
|
+
|
|
161
|
+
investors = search_result.get("investors", [])
|
|
162
|
+
if investors and len(investors) > 0:
|
|
163
|
+
found_investor = True
|
|
164
|
+
use_tracker = False
|
|
165
|
+
except requests.exceptions.RequestException:
|
|
166
|
+
# If search fails, continue to try tracker collection
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
# If not found in target_investors, try investor_tracker
|
|
170
|
+
if not found_investor:
|
|
171
|
+
try:
|
|
172
|
+
search_url = API_BASE_URL + "fintaAI/tools/search-tracker-entry"
|
|
173
|
+
search_params = {
|
|
174
|
+
"organizationHandle": handle,
|
|
175
|
+
}
|
|
176
|
+
if investorEmail:
|
|
177
|
+
search_params["email"] = investorEmail
|
|
178
|
+
elif investorName:
|
|
179
|
+
search_params["name"] = investorName
|
|
180
|
+
|
|
181
|
+
search_response = requests.get(search_url, params=search_params, timeout=10)
|
|
182
|
+
search_response.raise_for_status()
|
|
183
|
+
search_result = search_response.json()
|
|
184
|
+
|
|
185
|
+
entries = search_result.get("entries", [])
|
|
186
|
+
if entries and len(entries) > 0:
|
|
187
|
+
found_investor = True
|
|
188
|
+
use_tracker = True
|
|
189
|
+
except requests.exceptions.Timeout:
|
|
190
|
+
raise ToolException("Trouble with that request - the search timed out. Please try again.")
|
|
191
|
+
except requests.exceptions.HTTPError as e:
|
|
192
|
+
error_msg = "Trouble with that request"
|
|
193
|
+
try:
|
|
194
|
+
error_data = e.response.json()
|
|
195
|
+
if error_data.get('error'):
|
|
196
|
+
error_msg += f": {error_data['error']}"
|
|
197
|
+
except:
|
|
198
|
+
error_msg += f" - server error (status {e.response.status_code})"
|
|
199
|
+
raise ToolException(error_msg)
|
|
200
|
+
except requests.exceptions.RequestException as e:
|
|
201
|
+
raise ToolException(f"Trouble with that request - network error: {str(e)}")
|
|
202
|
+
|
|
203
|
+
# If still not found in either collection, raise error
|
|
204
|
+
if not found_investor or not search_result:
|
|
205
|
+
raise ToolException(f"Trouble with that request - no investor found with {'email: ' + investorEmail if investorEmail else 'name: ' + investorName} in either collection")
|
|
206
|
+
|
|
207
|
+
# Handle results based on collection type
|
|
208
|
+
if use_tracker:
|
|
209
|
+
entries = search_result.get("entries", [])
|
|
210
|
+
# entries should already be validated above, but double-check
|
|
211
|
+
if not entries or len(entries) == 0:
|
|
212
|
+
# This shouldn't happen if found_investor is True, but handle it anyway
|
|
213
|
+
raise ToolException(f"Trouble with that request - no tracker entry found with {'email: ' + investorEmail if investorEmail else 'name: ' + investorName}")
|
|
214
|
+
|
|
215
|
+
if len(entries) > 1:
|
|
216
|
+
# Multiple matches - try to narrow down by dealId or dealName
|
|
217
|
+
matched_entries = entries
|
|
218
|
+
multiple_matches_info = None
|
|
219
|
+
|
|
220
|
+
# Strategy 1: If dealId was provided, filter by dealId
|
|
221
|
+
if dealId:
|
|
222
|
+
deal_matches = [e for e in matched_entries if e.get('dealId') == dealId]
|
|
223
|
+
if len(deal_matches) == 1:
|
|
224
|
+
id = deal_matches[0]["id"]
|
|
225
|
+
matched_entries = deal_matches
|
|
226
|
+
elif len(deal_matches) > 1:
|
|
227
|
+
matched_entries = deal_matches
|
|
228
|
+
|
|
229
|
+
# Strategy 2: If dealName was provided, search for deal and filter by dealId
|
|
230
|
+
if dealName and len(matched_entries) > 1:
|
|
231
|
+
try:
|
|
232
|
+
search_deal_url = API_BASE_URL + "fintaAI/tools/search-deal"
|
|
233
|
+
search_deal_params = {
|
|
234
|
+
"organizationHandle": handle,
|
|
235
|
+
"dealName": dealName, # Use dealName parameter (searches both round and name fields)
|
|
236
|
+
}
|
|
237
|
+
search_deal_response = requests.get(search_deal_url, params=search_deal_params, timeout=10)
|
|
238
|
+
search_deal_response.raise_for_status()
|
|
239
|
+
search_deal_result = search_deal_response.json()
|
|
240
|
+
|
|
241
|
+
if search_deal_result.get("deals") and len(search_deal_result["deals"]) > 0:
|
|
242
|
+
deal_id_to_match = search_deal_result["deals"][0]["id"]
|
|
243
|
+
deal_matches = [e for e in matched_entries if e.get('dealId') == deal_id_to_match]
|
|
244
|
+
if len(deal_matches) == 1:
|
|
245
|
+
id = deal_matches[0]["id"]
|
|
246
|
+
matched_entries = deal_matches
|
|
247
|
+
elif len(deal_matches) > 1:
|
|
248
|
+
matched_entries = deal_matches
|
|
249
|
+
except:
|
|
250
|
+
# If deal search fails, continue with existing matches
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
# Strategy 3: If still multiple matches, pick the most recently modified one
|
|
254
|
+
if len(matched_entries) > 1:
|
|
255
|
+
# Simple approach: just pick the most recently updated entry
|
|
256
|
+
def get_updated_time(entry):
|
|
257
|
+
updated = entry.get('updated')
|
|
258
|
+
if hasattr(updated, 'timestamp'):
|
|
259
|
+
return updated.timestamp()
|
|
260
|
+
elif isinstance(updated, str):
|
|
261
|
+
from datetime import datetime
|
|
262
|
+
try:
|
|
263
|
+
return datetime.fromisoformat(updated.replace('Z', '+00:00')).timestamp()
|
|
264
|
+
except:
|
|
265
|
+
return 0
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
matched_entries.sort(key=get_updated_time, reverse=True)
|
|
269
|
+
id = matched_entries[0]["id"]
|
|
270
|
+
else:
|
|
271
|
+
# Only one match after filtering
|
|
272
|
+
id = matched_entries[0]["id"]
|
|
273
|
+
else:
|
|
274
|
+
id = entries[0]["id"]
|
|
275
|
+
else:
|
|
276
|
+
investors = search_result.get("investors", [])
|
|
277
|
+
if not investors or len(investors) == 0:
|
|
278
|
+
raise ToolException(f"Trouble with that request - no investor found with {'email: ' + investorEmail if investorEmail else 'name: ' + investorName}")
|
|
279
|
+
|
|
280
|
+
if len(investors) > 1:
|
|
281
|
+
# Multiple matches - try to narrow down intelligently
|
|
282
|
+
matched_investors = investors
|
|
283
|
+
|
|
284
|
+
# Strategy 1: If email was provided in the update, try to match by email
|
|
285
|
+
if email:
|
|
286
|
+
email_matches = [
|
|
287
|
+
inv for inv in matched_investors
|
|
288
|
+
if inv.get('investor', {}).get('email', '').lower() == email.lower()
|
|
289
|
+
]
|
|
290
|
+
if len(email_matches) == 1:
|
|
291
|
+
# Found exact email match - use it
|
|
292
|
+
id = email_matches[0]["id"]
|
|
293
|
+
elif len(email_matches) > 1:
|
|
294
|
+
# Still multiple matches even with email - pick most recent
|
|
295
|
+
matched_investors = email_matches
|
|
296
|
+
|
|
297
|
+
# Strategy 2: If still multiple, pick the most recently modified one
|
|
298
|
+
if len(matched_investors) > 1:
|
|
299
|
+
# Simple approach: just pick the most recently updated investor
|
|
300
|
+
def get_updated_time(inv):
|
|
301
|
+
updated = inv.get('updated')
|
|
302
|
+
if hasattr(updated, 'timestamp'):
|
|
303
|
+
return updated.timestamp()
|
|
304
|
+
elif isinstance(updated, str):
|
|
305
|
+
from datetime import datetime
|
|
306
|
+
try:
|
|
307
|
+
return datetime.fromisoformat(updated.replace('Z', '+00:00')).timestamp()
|
|
308
|
+
except:
|
|
309
|
+
return 0
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
matched_investors.sort(key=get_updated_time, reverse=True)
|
|
313
|
+
id = matched_investors[0]["id"]
|
|
314
|
+
else:
|
|
315
|
+
# Only one match after email filtering
|
|
316
|
+
id = matched_investors[0]["id"]
|
|
317
|
+
else:
|
|
318
|
+
# Found exactly one match
|
|
319
|
+
id = investors[0]["id"]
|
|
320
|
+
|
|
321
|
+
# If dealId not provided but dealName is, search for deal by name
|
|
322
|
+
resolved_deal_id = dealId
|
|
323
|
+
resolved_deal_uuid = dealUuid
|
|
324
|
+
if (not dealId or dealId == "") and dealName:
|
|
325
|
+
try:
|
|
326
|
+
search_deal_url = API_BASE_URL + "fintaAI/tools/search-deal"
|
|
327
|
+
# Use dealName parameter (searches both round and name fields) instead of round
|
|
328
|
+
search_deal_params = {
|
|
329
|
+
"organizationHandle": handle,
|
|
330
|
+
"dealName": dealName, # Search by deal name (searches both round and custom name fields)
|
|
331
|
+
}
|
|
332
|
+
search_deal_response = requests.get(search_deal_url, params=search_deal_params, timeout=10)
|
|
333
|
+
search_deal_response.raise_for_status()
|
|
334
|
+
search_deal_result = search_deal_response.json()
|
|
335
|
+
|
|
336
|
+
if not search_deal_result.get("deals") or len(search_deal_result["deals"]) == 0:
|
|
337
|
+
# Return user-friendly message instead of raising exception
|
|
338
|
+
return f"Trouble with that request - no deal found with name '{dealName}'. Please check the deal name and try again, or provide the deal ID if you have it."
|
|
339
|
+
|
|
340
|
+
if len(search_deal_result["deals"]) > 1:
|
|
341
|
+
# Multiple matches - use the first one (should be sorted by match score)
|
|
342
|
+
deal_list = "\n".join([
|
|
343
|
+
f"- {deal.get('name') or deal.get('terms', {}).get('round', 'Unknown')} (ID: {deal['id']}, Score: {deal.get('matchScore', 0)})"
|
|
344
|
+
for deal in search_deal_result["deals"][:5]
|
|
345
|
+
])
|
|
346
|
+
# Use the first match (should be highest score)
|
|
347
|
+
resolved_deal_id = search_deal_result["deals"][0]["id"]
|
|
348
|
+
resolved_deal_uuid = search_deal_result["deals"][0].get("uuid", "")
|
|
349
|
+
first_deal = search_deal_result["deals"][0]
|
|
350
|
+
logger.debug(f"DEBUG: Found {len(search_deal_result['deals'])} deals matching '{dealName}'. Using first match (ID: {resolved_deal_id}, Round: {first_deal.get('round', 'N/A')}, Name: {first_deal.get('name', 'N/A')}, Nickname: {first_deal.get('dealNickname', 'N/A')}, Score: {first_deal.get('matchScore', 0)})")
|
|
351
|
+
else:
|
|
352
|
+
# Found exactly one match
|
|
353
|
+
resolved_deal_id = search_deal_result["deals"][0]["id"]
|
|
354
|
+
resolved_deal_uuid = search_deal_result["deals"][0].get("uuid", "")
|
|
355
|
+
first_deal = search_deal_result["deals"][0]
|
|
356
|
+
logger.debug(f"DEBUG: Found exact match for '{dealName}' (ID: {resolved_deal_id}, Round: {first_deal.get('round', 'N/A')}, Name: {first_deal.get('name', 'N/A')}, Nickname: {first_deal.get('dealNickname', 'N/A')})")
|
|
357
|
+
except requests.exceptions.Timeout:
|
|
358
|
+
return "Trouble with that request - the deal search timed out. Please try again."
|
|
359
|
+
except requests.exceptions.HTTPError as e:
|
|
360
|
+
error_msg = "Trouble with that request"
|
|
361
|
+
try:
|
|
362
|
+
error_data = e.response.json()
|
|
363
|
+
if error_data.get('error'):
|
|
364
|
+
error_msg += f": {error_data['error']}"
|
|
365
|
+
except:
|
|
366
|
+
error_msg += f" - server error (status {e.response.status_code})"
|
|
367
|
+
return error_msg
|
|
368
|
+
except requests.exceptions.RequestException as e:
|
|
369
|
+
return f"Trouble with that request - network error: {str(e)}"
|
|
370
|
+
|
|
371
|
+
investorFields = {}
|
|
372
|
+
fields = {}
|
|
373
|
+
|
|
374
|
+
# Map user-friendly status names to database values
|
|
375
|
+
status_mapping = {
|
|
376
|
+
'target investor': 'target',
|
|
377
|
+
'target': 'target',
|
|
378
|
+
'in contact': 'emailed',
|
|
379
|
+
'emailed': 'emailed',
|
|
380
|
+
'viewed deal': 'viewed',
|
|
381
|
+
'viewed': 'viewed',
|
|
382
|
+
'interested': 'interested',
|
|
383
|
+
'diligence': 'diligence',
|
|
384
|
+
'committed': 'committed',
|
|
385
|
+
'money in the bank': 'invested',
|
|
386
|
+
'invested': 'invested',
|
|
387
|
+
'passed': 'passed',
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# Normalize status if provided
|
|
391
|
+
if status:
|
|
392
|
+
status_lower = status.lower().strip()
|
|
393
|
+
normalized_status = status_mapping.get(status_lower, status_lower)
|
|
394
|
+
status = normalized_status
|
|
395
|
+
|
|
396
|
+
# Investor (nested) fields
|
|
397
|
+
for key, value in [
|
|
398
|
+
("firstName", firstName),
|
|
399
|
+
("lastName", lastName),
|
|
400
|
+
("email", email),
|
|
401
|
+
("phone", phone),
|
|
402
|
+
("location", location),
|
|
403
|
+
("type", type),
|
|
404
|
+
("title", title),
|
|
405
|
+
("organizationName", organizationName),
|
|
406
|
+
("organizationWebsite", organizationWebsite),
|
|
407
|
+
("linkedIn", linkedIn),
|
|
408
|
+
("twitter", twitter),
|
|
409
|
+
("crunchbase", crunchbase),
|
|
410
|
+
("angelList", angelList),
|
|
411
|
+
("investmentRangeFrom", investmentRangeFrom),
|
|
412
|
+
("investmentRangeTo", investmentRangeTo),
|
|
413
|
+
("investmentSweetSpot", investmentSweetSpot),
|
|
414
|
+
("fundSize", fundSize),
|
|
415
|
+
("preferredAssetClass", preferredAssetClass),
|
|
416
|
+
("prefferedCompanyTraction", prefferedCompanyTraction),
|
|
417
|
+
("preferredGeographicInvestmentFocus", preferredGeographicInvestmentFocus),
|
|
418
|
+
("gender", gender),
|
|
419
|
+
("bio", bio),
|
|
420
|
+
("relevantPortfolioCompanies", relevantPortfolioCompanies),
|
|
421
|
+
]:
|
|
422
|
+
if value not in (None, ""):
|
|
423
|
+
investorFields[key] = value
|
|
424
|
+
|
|
425
|
+
if leadsInvestments is not None:
|
|
426
|
+
investorFields["leadsInvestments"] = leadsInvestments
|
|
427
|
+
if focusStages is not None:
|
|
428
|
+
investorFields["focusStages"] = focusStages
|
|
429
|
+
if industriesOfInterest is not None:
|
|
430
|
+
investorFields["industriesOfInterest"] = industriesOfInterest
|
|
431
|
+
if industriesNotInterestedIn is not None:
|
|
432
|
+
investorFields["industriesNotInterestedIn"] = industriesNotInterestedIn
|
|
433
|
+
if tags is not None:
|
|
434
|
+
# Tags are stored as a top-level field on the target investor document.
|
|
435
|
+
# Ensure we only send valid, non-empty string tags
|
|
436
|
+
if isinstance(tags, list):
|
|
437
|
+
cleaned_tags = []
|
|
438
|
+
for tag in tags:
|
|
439
|
+
if tag is None:
|
|
440
|
+
continue
|
|
441
|
+
if isinstance(tag, str):
|
|
442
|
+
stripped = tag.strip()
|
|
443
|
+
if stripped:
|
|
444
|
+
cleaned_tags.append(stripped)
|
|
445
|
+
else:
|
|
446
|
+
# If tag is not a string, convert to string
|
|
447
|
+
stripped = str(tag).strip()
|
|
448
|
+
if stripped:
|
|
449
|
+
cleaned_tags.append(stripped)
|
|
450
|
+
fields["tags"] = cleaned_tags
|
|
451
|
+
else:
|
|
452
|
+
# If single string provided by mistake, wrap it
|
|
453
|
+
fields["tags"] = [str(tags).strip()] if str(tags).strip() else []
|
|
454
|
+
|
|
455
|
+
# Top-level fields
|
|
456
|
+
# Status is already normalized above, use it directly
|
|
457
|
+
# IMPORTANT: Always set dealId/dealUuid if resolved, even if empty string (to clear existing deal)
|
|
458
|
+
if resolved_deal_id:
|
|
459
|
+
fields["dealId"] = resolved_deal_id
|
|
460
|
+
elif dealId:
|
|
461
|
+
fields["dealId"] = dealId
|
|
462
|
+
|
|
463
|
+
if resolved_deal_uuid:
|
|
464
|
+
fields["dealUuid"] = resolved_deal_uuid
|
|
465
|
+
elif dealUuid:
|
|
466
|
+
fields["dealUuid"] = dealUuid
|
|
467
|
+
|
|
468
|
+
# Other top-level fields
|
|
469
|
+
for key, value in [
|
|
470
|
+
("status", status),
|
|
471
|
+
("grade", grade),
|
|
472
|
+
("amount", amount),
|
|
473
|
+
("paymentMethod", paymentMethod),
|
|
474
|
+
("potentialIntroductionName", potentialIntroductionName),
|
|
475
|
+
("potentialIntroduction", potentialIntroduction),
|
|
476
|
+
("potentialIntroductionLinkedin", potentialIntroductionLinkedin),
|
|
477
|
+
("notes", notes),
|
|
478
|
+
("feedback", feedback),
|
|
479
|
+
("sourceUserId", sourceUserId),
|
|
480
|
+
]:
|
|
481
|
+
if value not in (None, ""):
|
|
482
|
+
fields[key] = value
|
|
483
|
+
|
|
484
|
+
# Handle introductionPaths array (takes precedence over individual fields)
|
|
485
|
+
if introductionPaths is not None:
|
|
486
|
+
# Validate and clean introduction paths
|
|
487
|
+
cleaned_paths = []
|
|
488
|
+
for path in introductionPaths:
|
|
489
|
+
cleaned_path = {}
|
|
490
|
+
if isinstance(path, dict):
|
|
491
|
+
if path.get("name"):
|
|
492
|
+
cleaned_path["name"] = path["name"]
|
|
493
|
+
if path.get("email"):
|
|
494
|
+
cleaned_path["email"] = path["email"]
|
|
495
|
+
if path.get("linkedin"):
|
|
496
|
+
cleaned_path["linkedin"] = path["linkedin"]
|
|
497
|
+
# Respect primary flag from path dict, or use setAsPrimaryIntroduction if provided
|
|
498
|
+
if path.get("primary") is not None:
|
|
499
|
+
cleaned_path["primary"] = bool(path["primary"])
|
|
500
|
+
elif setAsPrimaryIntroduction is not None and len(cleaned_paths) == 0:
|
|
501
|
+
# Only apply setAsPrimaryIntroduction to first path if not already set
|
|
502
|
+
cleaned_path["primary"] = bool(setAsPrimaryIntroduction)
|
|
503
|
+
if cleaned_path: # Only add if path has at least one field
|
|
504
|
+
cleaned_paths.append(cleaned_path)
|
|
505
|
+
if cleaned_paths:
|
|
506
|
+
fields["introductionPaths"] = cleaned_paths
|
|
507
|
+
# Auto-convert legacy introduction fields to introductionPaths if not already set
|
|
508
|
+
elif (potentialIntroductionName or potentialIntroduction or potentialIntroductionLinkedin):
|
|
509
|
+
# Build introduction path from legacy fields
|
|
510
|
+
intro_path = {}
|
|
511
|
+
|
|
512
|
+
# Clean and handle potentialIntroductionName (strip whitespace, non-breaking spaces, etc.)
|
|
513
|
+
if potentialIntroductionName:
|
|
514
|
+
cleaned_name = potentialIntroductionName.strip().replace('\xa0', ' ').strip()
|
|
515
|
+
if cleaned_name:
|
|
516
|
+
intro_path["name"] = cleaned_name
|
|
517
|
+
|
|
518
|
+
# Handle potentialIntroduction - could be name or email
|
|
519
|
+
if potentialIntroduction:
|
|
520
|
+
cleaned_intro = potentialIntroduction.strip().replace('\xa0', ' ').strip()
|
|
521
|
+
if cleaned_intro:
|
|
522
|
+
# Check if it looks like an email
|
|
523
|
+
if '@' in cleaned_intro:
|
|
524
|
+
intro_path["email"] = cleaned_intro
|
|
525
|
+
else:
|
|
526
|
+
# If no name set yet, use this as name
|
|
527
|
+
if not intro_path.get("name"):
|
|
528
|
+
intro_path["name"] = cleaned_intro
|
|
529
|
+
else:
|
|
530
|
+
# If name already set, assume this is email
|
|
531
|
+
intro_path["email"] = cleaned_intro
|
|
532
|
+
|
|
533
|
+
# Extract email from notes if present (e.g., "Email: megan@googl.com")
|
|
534
|
+
if notes and not intro_path.get("email"):
|
|
535
|
+
import re
|
|
536
|
+
email_match = re.search(r'[Ee]mail:\s*([^\s,]+@[^\s,]+)', notes)
|
|
537
|
+
if email_match:
|
|
538
|
+
intro_path["email"] = email_match.group(1).strip()
|
|
539
|
+
|
|
540
|
+
if potentialIntroductionLinkedin:
|
|
541
|
+
cleaned_linkedin = potentialIntroductionLinkedin.strip().replace('\xa0', ' ').strip()
|
|
542
|
+
if cleaned_linkedin:
|
|
543
|
+
intro_path["linkedin"] = cleaned_linkedin
|
|
544
|
+
|
|
545
|
+
# Only set primary if user explicitly requested it
|
|
546
|
+
# Otherwise, let the system decide (first path becomes primary by default)
|
|
547
|
+
if setAsPrimaryIntroduction is True:
|
|
548
|
+
intro_path["primary"] = True
|
|
549
|
+
elif setAsPrimaryIntroduction is False:
|
|
550
|
+
intro_path["primary"] = False
|
|
551
|
+
# If setAsPrimaryIntroduction is None, don't set primary field - let system default
|
|
552
|
+
|
|
553
|
+
# Only add if we have at least a name or email
|
|
554
|
+
if intro_path.get("name") or intro_path.get("email"):
|
|
555
|
+
fields["introductionPaths"] = [intro_path]
|
|
556
|
+
logger.info(f"Auto-converted legacy intro fields to introductionPaths: {fields['introductionPaths']}")
|
|
557
|
+
|
|
558
|
+
# Handle noConnections flag
|
|
559
|
+
if noConnections is not None:
|
|
560
|
+
fields["noConnections"] = noConnections
|
|
561
|
+
|
|
562
|
+
# Ensure we have something to update
|
|
563
|
+
if not investorFields and not fields:
|
|
564
|
+
raise ToolException("Trouble with that request - no fields provided to update")
|
|
565
|
+
|
|
566
|
+
# Use appropriate update endpoint based on collection type
|
|
567
|
+
if use_tracker:
|
|
568
|
+
# Update tracker entry - investorFields go to INVESTOR_TRACKER_INVESTORS collection
|
|
569
|
+
url = API_BASE_URL + "fintaAI/tools/update-tracker-entry"
|
|
570
|
+
payload = {
|
|
571
|
+
"data": {
|
|
572
|
+
"id": id,
|
|
573
|
+
"organizationId": handle,
|
|
574
|
+
"sourceUserId": sourceUserId,
|
|
575
|
+
"investorFields": investorFields, # These update INVESTOR_TRACKER_INVESTORS
|
|
576
|
+
"fields": fields, # These update the tracker entry itself
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else:
|
|
580
|
+
# Update target investor (has nested investorFields)
|
|
581
|
+
url = API_BASE_URL + "fintaAI/tools/update-target-investor"
|
|
582
|
+
payload = {
|
|
583
|
+
"data": {
|
|
584
|
+
"id": id,
|
|
585
|
+
"organizationId": handle,
|
|
586
|
+
"sourceUserId": sourceUserId,
|
|
587
|
+
"investorFields": investorFields,
|
|
588
|
+
"fields": fields,
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
# Debug: Log what we're sending
|
|
594
|
+
if resolved_deal_id:
|
|
595
|
+
logger.debug(f"DEBUG: Updating dealId to: {resolved_deal_id} (from dealName: {dealName})")
|
|
596
|
+
elif dealId:
|
|
597
|
+
logger.debug(f"DEBUG: Updating dealId to: {dealId} (provided directly)")
|
|
598
|
+
|
|
599
|
+
response = requests.post(url, json=payload, timeout=10)
|
|
600
|
+
response.raise_for_status()
|
|
601
|
+
result = response.json()
|
|
602
|
+
|
|
603
|
+
collection_name = "tracker entry" if use_tracker else "investor"
|
|
604
|
+
success_msg = f"Success! {collection_name.capitalize()} updated."
|
|
605
|
+
if result.get('message'):
|
|
606
|
+
success_msg += f" {result.get('message')}"
|
|
607
|
+
|
|
608
|
+
# Include deal info in success message if deal was updated
|
|
609
|
+
if resolved_deal_id or dealId:
|
|
610
|
+
deal_info = resolved_deal_id if resolved_deal_id else dealId
|
|
611
|
+
success_msg += f" Deal ID set to: {deal_info}"
|
|
612
|
+
|
|
613
|
+
return success_msg
|
|
614
|
+
except requests.exceptions.Timeout:
|
|
615
|
+
raise ToolException("Trouble with that request - the update timed out. Please try again.")
|
|
616
|
+
except requests.exceptions.HTTPError as e:
|
|
617
|
+
error_msg = "Trouble with that request"
|
|
618
|
+
try:
|
|
619
|
+
error_data = e.response.json()
|
|
620
|
+
if error_data.get('error'):
|
|
621
|
+
error_msg += f": {error_data['error']}"
|
|
622
|
+
except:
|
|
623
|
+
if e.response.status_code == 404:
|
|
624
|
+
error_msg += " - the contact was not found"
|
|
625
|
+
elif e.response.status_code == 403:
|
|
626
|
+
error_msg += " - not authorized to update this contact"
|
|
627
|
+
elif e.response.status_code == 400:
|
|
628
|
+
error_msg += " - invalid request data"
|
|
629
|
+
else:
|
|
630
|
+
error_msg += f" - server error (status {e.response.status_code})"
|
|
631
|
+
raise ToolException(error_msg)
|
|
632
|
+
except requests.exceptions.RequestException as e:
|
|
633
|
+
raise ToolException(f"Trouble with that request - network error: {str(e)}")
|
|
634
|
+
|
|
635
|
+
except ToolException:
|
|
636
|
+
# Re-raise ToolException as-is (already has user-friendly message)
|
|
637
|
+
raise
|
|
638
|
+
except Exception as e:
|
|
639
|
+
# Catch any unexpected errors and format them nicely
|
|
640
|
+
import traceback
|
|
641
|
+
logger.error(f"Unexpected error in editInvestorTool: {str(e)}", exc_info=True)
|
|
642
|
+
logger.error(traceback.format_exc())
|
|
643
|
+
raise ToolException(f"Trouble with that request - unexpected error: {str(e)}")
|
|
644
|
+
|
|
645
|
+
edit_target_investor_any_fields_tool = StructuredTool.from_function(
|
|
646
|
+
func=call_edit_target_investor_any_fields,
|
|
647
|
+
name="EditTargetInvestorAnyFields",
|
|
648
|
+
description="Given an input of investor information to update, this function updates any fields on a target investor document or tracker entry. Can search by investorName or investorEmail if ID is not provided. Can search for deals by dealName (e.g., 'Seed', 'Series B', 'Custom Terms', or deal nickname) if dealId is not provided. The deal search matches against the full deal title (round, name, and nickname), same as the deal selector in Aurora. Works with both 'target_investors' (manually added investors) and 'investor_tracker' (contacts created when viewing deals) collections. Use investorName parameter (e.g., 'Kevin Siskar') to search if ID unknown. Use dealName parameter (e.g., 'Seed', 'Custom Terms', or deal nickname) when user says 'update deal to Seed' or 'assign to Custom Terms deal'. Use collectionType='investor_tracker' when updating contacts that viewed a deal. IMPORTANT: When there are multiple tracker entries for the same investor (e.g., same person viewed multiple deals), you MUST specify dealName to target a specific entry. If dealName is not specified and multiple entries exist, the tool will ask the user to specify which deal. Note: Investor details (name, organization, email) are stored in a shared document, so updating these fields affects ALL tracker entries for that investor. When user says 'tag [name] as [tag]' or 'add tag [tag]', use the 'tags' parameter with an array (e.g., tags=['Super Angel']). Tags are labels/categories, NOT the same as 'grade' (which is a single rating). Required: investorName or investorEmail or id, plus fields to update (status, email, notes, tags, dealName, etc.).",
|
|
649
|
+
args_schema=EditTargetInvestorInput,
|
|
650
|
+
return_direct=False,
|
|
651
|
+
verbose=True,
|
|
652
|
+
)
|
|
653
|
+
|