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.
@@ -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
+