dhisana 0.0.1.dev85__py3-none-any.whl → 0.0.1.dev236__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 (70) hide show
  1. dhisana/schemas/common.py +33 -0
  2. dhisana/schemas/sales.py +224 -23
  3. dhisana/utils/add_mapping.py +72 -63
  4. dhisana/utils/apollo_tools.py +739 -109
  5. dhisana/utils/built_with_api_tools.py +4 -2
  6. dhisana/utils/cache_output_tools.py +23 -23
  7. dhisana/utils/check_email_validity_tools.py +456 -458
  8. dhisana/utils/check_for_intent_signal.py +1 -2
  9. dhisana/utils/check_linkedin_url_validity.py +34 -8
  10. dhisana/utils/clay_tools.py +3 -2
  11. dhisana/utils/clean_properties.py +3 -1
  12. dhisana/utils/compose_salesnav_query.py +0 -1
  13. dhisana/utils/compose_search_query.py +7 -3
  14. dhisana/utils/composite_tools.py +0 -1
  15. dhisana/utils/dataframe_tools.py +2 -2
  16. dhisana/utils/email_body_utils.py +72 -0
  17. dhisana/utils/email_provider.py +375 -0
  18. dhisana/utils/enrich_lead_information.py +585 -85
  19. dhisana/utils/fetch_openai_config.py +129 -0
  20. dhisana/utils/field_validators.py +1 -1
  21. dhisana/utils/g2_tools.py +0 -1
  22. dhisana/utils/generate_content.py +0 -1
  23. dhisana/utils/generate_email.py +69 -16
  24. dhisana/utils/generate_email_response.py +298 -41
  25. dhisana/utils/generate_flow.py +0 -1
  26. dhisana/utils/generate_linkedin_connect_message.py +19 -6
  27. dhisana/utils/generate_linkedin_response_message.py +156 -65
  28. dhisana/utils/generate_structured_output_internal.py +351 -131
  29. dhisana/utils/google_custom_search.py +150 -44
  30. dhisana/utils/google_oauth_tools.py +721 -0
  31. dhisana/utils/google_workspace_tools.py +391 -25
  32. dhisana/utils/hubspot_clearbit.py +3 -1
  33. dhisana/utils/hubspot_crm_tools.py +771 -167
  34. dhisana/utils/instantly_tools.py +3 -1
  35. dhisana/utils/lusha_tools.py +10 -7
  36. dhisana/utils/mailgun_tools.py +150 -0
  37. dhisana/utils/microsoft365_tools.py +447 -0
  38. dhisana/utils/openai_assistant_and_file_utils.py +121 -177
  39. dhisana/utils/openai_helpers.py +19 -16
  40. dhisana/utils/parse_linkedin_messages_txt.py +2 -3
  41. dhisana/utils/profile.py +37 -0
  42. dhisana/utils/proxy_curl_tools.py +507 -206
  43. dhisana/utils/proxycurl_search_leads.py +426 -0
  44. dhisana/utils/research_lead.py +121 -68
  45. dhisana/utils/sales_navigator_crawler.py +1 -6
  46. dhisana/utils/salesforce_crm_tools.py +323 -50
  47. dhisana/utils/search_router.py +131 -0
  48. dhisana/utils/search_router_jobs.py +51 -0
  49. dhisana/utils/sendgrid_tools.py +126 -91
  50. dhisana/utils/serarch_router_local_business.py +75 -0
  51. dhisana/utils/serpapi_additional_tools.py +290 -0
  52. dhisana/utils/serpapi_google_jobs.py +117 -0
  53. dhisana/utils/serpapi_google_search.py +188 -0
  54. dhisana/utils/serpapi_local_business_search.py +129 -0
  55. dhisana/utils/serpapi_search_tools.py +363 -432
  56. dhisana/utils/serperdev_google_jobs.py +125 -0
  57. dhisana/utils/serperdev_local_business.py +154 -0
  58. dhisana/utils/serperdev_search.py +233 -0
  59. dhisana/utils/smtp_email_tools.py +576 -0
  60. dhisana/utils/test_connect.py +1765 -92
  61. dhisana/utils/trasform_json.py +95 -16
  62. dhisana/utils/web_download_parse_tools.py +0 -1
  63. dhisana/utils/zoominfo_tools.py +2 -3
  64. dhisana/workflow/test.py +1 -1
  65. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +5 -2
  66. dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
  67. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
  68. dhisana-0.0.1.dev85.dist-info/RECORD +0 -81
  69. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
  70. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
dhisana/schemas/common.py CHANGED
@@ -363,3 +363,36 @@ class Integration(IntegrationBase):
363
363
 
364
364
  Integration.model_rebuild()
365
365
  IntegrationUpdate.model_rebuild()
366
+
367
+ class BodyFormat(str, Enum):
368
+ AUTO = "auto"
369
+ HTML = "html"
370
+ TEXT = "text"
371
+
372
+
373
+ class SendEmailContext(BaseModel):
374
+ recipient: str
375
+ subject: str
376
+ body: str
377
+ sender_name: str
378
+ sender_email: str
379
+ labels: Optional[List[str]]
380
+ body_format: BodyFormat = BodyFormat.AUTO
381
+
382
+ class QueryEmailContext(BaseModel):
383
+ start_time: str
384
+ end_time: str
385
+ sender_email: str
386
+ unread_only: bool = True
387
+ labels: Optional[List[str]] = None
388
+
389
+
390
+ class ReplyEmailContext(BaseModel):
391
+ message_id: str
392
+ reply_body: str
393
+ sender_email: str
394
+ sender_name: str
395
+ fallback_recipient: Optional[str] = None
396
+ mark_as_read: str = "True"
397
+ add_labels: Optional[List[str]] = None
398
+ reply_body_format: BodyFormat = BodyFormat.AUTO
dhisana/schemas/sales.py CHANGED
@@ -1,24 +1,31 @@
1
+ import json
2
+
1
3
  from uuid import UUID
2
- from pydantic import BaseModel, Field
4
+ from pydantic import BaseModel, Field, field_validator
3
5
  from typing import List, Optional, Dict, Any
4
6
  from enum import Enum
5
7
  from typing import Optional, List, Dict, Literal
6
8
 
7
- from dhisana.schemas.common import User
8
9
 
9
10
  # -----------------------------
10
11
  # Lead-List-Specific Schemas
11
12
  # -----------------------------
12
13
 
13
14
  class Lead(BaseModel):
14
- id: Optional[str] = None
15
+ id: Optional[UUID] = None
15
16
  full_name: Optional[str] = None
16
17
  first_name: Optional[str] = None
17
18
  last_name: Optional[str] = None
18
19
  email: Optional[str] = None
19
20
  user_linkedin_url: Optional[str] = None
20
21
  user_linkedin_salesnav_url: Optional[str] = None
22
+ organization_linkedin_url: Optional[str] = None
23
+ organization_linkedin_salesnav_url: Optional[str] = None
24
+ linkedin_follower_count: Optional[int] = None
21
25
  primary_domain_of_organization: Optional[str] = None
26
+ twitter_handle: Optional[str] = None
27
+ twitch_handle: Optional[str] = None
28
+ github_handle: Optional[str] = None
22
29
  job_title: Optional[str] = None
23
30
  phone: Optional[str] = None
24
31
  headline: Optional[str] = None
@@ -26,25 +33,69 @@ class Lead(BaseModel):
26
33
  organization_name: Optional[str] = None
27
34
  organization_website: Optional[str] = None
28
35
  summary_about_lead: Optional[str] = None
36
+
37
+ qualification_score: Optional[float] = None
38
+ qualification_reason: Optional[str] = None
39
+ revenue: Optional[str] = None
40
+ company_size: Optional[str] = None
41
+ industry: Optional[str] = None
42
+
43
+ keywords: Optional[Any] = None
44
+ tags: List[str] = []
45
+ notes: List[str] = []
46
+ additional_properties: Optional[Dict[str, Any]] = {}
29
47
  workflow_stage: Optional[str] = None
30
- assigned_to: Optional[str] = None
31
- engaged: Optional[bool] = None
48
+
49
+ engaged: bool = False
32
50
  last_contact: Optional[int] = None
33
- additional_properties: Optional[Dict[str, str]] = None
34
51
  research_summary: Optional[str] = None
35
52
  task_ids: Optional[List[str]] = None
36
- email_validation_status: Optional[
37
- Literal["not_started", "in_progress", "valid", "invalid"]
38
- ] = None
39
- linkedin_validation_status: Optional[
40
- Literal["not_started", "in_progress", "valid", "invalid"]
41
- ] = None
42
- research_status: Optional[
43
- Literal["not_started", "in_progress", "done", "failed"]
44
- ] = None
45
- enchrichment_status: Optional[
46
- Literal["not_started", "in_progress", "done", "failed"]
47
- ] = None
53
+ email_validation_status: Optional[str] = None
54
+ linkedin_validation_status: Optional[str] = None
55
+ research_status: Optional[str] = None
56
+ enchrichment_status: Optional[str] = None
57
+
58
+
59
+ @field_validator("linkedin_follower_count", mode="before")
60
+ @classmethod
61
+ def parse_linkedin_follower_count(cls, v):
62
+ if v is None or v == "":
63
+ return None
64
+ if isinstance(v, str):
65
+ v = v.strip()
66
+ if v == "":
67
+ return None
68
+ try:
69
+ return int(v)
70
+ except ValueError:
71
+ raise ValueError("linkedin_follower_count must be an integer")
72
+ return v
73
+
74
+ @field_validator("notes", mode="before")
75
+ @classmethod
76
+ def ensure_notes_list(cls, v):
77
+ """Coerce notes to a list of strings.
78
+ Handles legacy cases where the DB may contain a scalar or JSON string.
79
+ """
80
+ if v is None:
81
+ return []
82
+ if isinstance(v, list):
83
+ # Ensure all elements are strings
84
+ return [str(item) if not isinstance(item, str) else item for item in v]
85
+ if isinstance(v, str):
86
+ # Try to parse JSON array; if not, wrap as single-note list
87
+ try:
88
+ parsed = json.loads(v)
89
+ if isinstance(parsed, list):
90
+ return [str(item) if not isinstance(item, str) else item for item in parsed]
91
+ except Exception:
92
+ pass
93
+ return [v]
94
+ # Fallback: wrap any other scalar/object as a single string entry
95
+ try:
96
+ return [json.dumps(v)]
97
+ except Exception:
98
+ return [str(v)]
48
99
 
49
100
 
50
101
  class LeadList(BaseModel):
@@ -240,9 +291,14 @@ class MessageGenerationInstructions(BaseModel):
240
291
  Holds the user-supplied instructions for generating the message:
241
292
  - instructions_to_generate_message: Plain text or template instructions from the user.
242
293
  - prompt_engineering_guidance: (Optional) Extra guidelines for structuring the prompt.
294
+ - allow_html: Whether HTML output is allowed.
295
+ - html_template: Optional HTML scaffolding or guidance.
243
296
  """
244
297
  instructions_to_generate_message: Optional[str] = None
245
298
  prompt_engineering_guidance: Optional[PromptEngineeringGuidance] = None
299
+ use_cache: Optional[bool] = True
300
+ allow_html: Optional[bool] = False
301
+ html_template: Optional[str] = None
246
302
 
247
303
  class CampaignContext(BaseModel):
248
304
  """
@@ -272,6 +328,10 @@ class MessageItem(BaseModel):
272
328
  ...,
273
329
  description="Unique identifier for the message"
274
330
  )
331
+ thread_id: str = Field(
332
+ ...,
333
+ description="Unique identifier for the conversation thread"
334
+ )
275
335
  sender_name: str = Field(
276
336
  ...,
277
337
  description="Sender's display name (if available)"
@@ -300,6 +360,7 @@ class MessageItem(BaseModel):
300
360
  ...,
301
361
  description="Body of the message in plain text"
302
362
  )
363
+ html_body: Optional[str] = None
303
364
 
304
365
  class MessageResponse(BaseModel):
305
366
  """
@@ -435,14 +496,14 @@ class HubSpotLeadInformation(BaseModel):
435
496
  organization_name: str = Field("", description="Current Company where lead works")
436
497
  organization_website: str = Field("", description="Current Company website of the lead")
437
498
  organization_linkedin_url : str = Field("", description="Company LinkedIn URL")
438
- additional_properties: Optional[Dict[str, str]] = None
499
+ additional_properties: Optional[Dict[str, Any]] = None
439
500
 
440
501
  class HubSpotCompanyinformation(BaseModel):
441
502
  primary_domain_of_organization: str = Field("", description="Primary domain of the organization")
442
503
  organization_name: str = Field("", description="Current Company where lead works")
443
504
  organization_website: str = Field("", description="Current Company website of the lead")
444
505
  organization_linkedin_url : str = Field("", description="Company LinkedIn URL")
445
- additional_properties: Optional[Dict[str, str]] = None
506
+ additional_properties: Optional[Dict[str, Any]] = None
446
507
 
447
508
 
448
509
  # --------------------------------------------------------------------
@@ -459,6 +520,7 @@ HUBSPOT_TO_LEAD_MAPPING = {
459
520
  "address": "lead_location", # You can choose "city", "state", etc. if you prefer
460
521
  "city": "lead_location",
461
522
  "domain": "primary_domain_of_organization",
523
+ "hs_linkedin_url": "user_linkedin_url",
462
524
  }
463
525
 
464
526
  class SmartListStatus(str, Enum):
@@ -478,6 +540,12 @@ class SmartListSourceType(str, Enum):
478
540
  CSV = "CSV"
479
541
  GOOGLE_SHEETS = "GOOGLE_SHEETS"
480
542
  CUSTOM_WEBSITE = "CUSTOM_WEBSITE"
543
+ GITHUB = "GITHUB"
544
+ ICP_SEARCH = "ICP_SEARCH"
545
+ LOCAL_BUSINESS = "LOCAL_BUSINESS"
546
+ GOOGLE_JOBS = "GOOGLE_JOBS"
547
+ WEBHOOK = "WEBHOOK"
548
+ GOOGLE_CUSTOM_SITE_SEARCH = "GOOGLE_CUSTOM_SITE_SEARCH"
481
549
 
482
550
  class SourceConfiguration(BaseModel):
483
551
  """
@@ -494,6 +562,15 @@ class SourceConfiguration(BaseModel):
494
562
  file_path: Optional[str] = None
495
563
  # For Google Sheets
496
564
  source_url: Optional[str] = None
565
+ # For Github
566
+ github_search_query: Optional[str] = None
567
+ github_max_repos: Optional[int] = None
568
+ github_max_contributors: Optional[int] = None
569
+
570
+ # Custom website inputs
571
+ custom_instructions_for_doing_pagination: Optional[str] = None
572
+ custom_instructions_for_data_extraction_from_page: Optional[str] = None
573
+ custom_instruction_to_fetch_details_page: Optional[str] = None
497
574
 
498
575
  class SmartListSource(BaseModel):
499
576
  """
@@ -555,6 +632,8 @@ class SmartListLead(BaseModel):
555
632
  organization_linkedin_url: Optional[str] = None
556
633
  organization_linkedin_salesnav_url: Optional[str] = None
557
634
  primary_domain_of_organization: Optional[str] = None
635
+ twitter_handle: Optional[str] = None
636
+ github_handle: Optional[str] = None
558
637
  job_title: Optional[str] = None
559
638
  phone: Optional[str] = None
560
639
  headline: Optional[str] = None
@@ -562,13 +641,18 @@ class SmartListLead(BaseModel):
562
641
  organization_name: Optional[str] = None
563
642
  organization_website: Optional[str] = None
564
643
  summary_about_lead: Optional[str] = None
565
- keywords: Optional[List[str]] = None
566
- additional_properties: Optional[Dict[str, str]] = None
644
+ keywords: Optional[Any] = None
645
+ additional_properties: Optional[Dict[str, Any]] = None
567
646
  research_summary: Optional[str] = None
568
647
 
569
648
  qualification_score: Optional[float] = None
570
649
  qualification_reason: Optional[str] = None
571
650
  source: Optional[str] = None
651
+
652
+ email_validation_status: Optional[str] = None
653
+ linkedin_validation_status: Optional[str] = None
654
+ research_status: Optional[str] = None
655
+ enchrichment_status: Optional[str] = None
572
656
 
573
657
  agent_instance_id: Optional[UUID] = None
574
658
  organization_id: Optional[UUID] = None
@@ -576,7 +660,10 @@ class SmartListLead(BaseModel):
576
660
  created_at: Optional[int] = None
577
661
  updated_by: Optional[UUID] = None
578
662
  updated_at: Optional[int] = None
579
-
663
+
664
+ revenue: Optional[str] = None
665
+ company_size: Optional[str] = None
666
+ industry: Optional[str] = None
580
667
  class Config:
581
668
  from_attributes = True
582
669
 
@@ -741,3 +828,117 @@ class LeadsQueryFilters(BaseModel):
741
828
  description="Ranges for organization number of employees."
742
829
  )
743
830
 
831
+
832
+ class CompanyQueryFilters(BaseModel):
833
+ """
834
+ Defines the filter parameters for querying companies/organizations in the Apollo database.
835
+ All fields are optional and default to None if not specified by user.
836
+ """
837
+
838
+ # Core company search parameters
839
+ organization_locations: Optional[List[str]] = Field(
840
+ default=None,
841
+ description="List of organization headquarters locations (city, state, country)."
842
+ )
843
+
844
+ organization_num_employees_ranges: Optional[List[str]] = Field(
845
+ default=None,
846
+ description="Employee count ranges, e.g. ['1,10', '11,50', '51,200']. Use specific ranges."
847
+ )
848
+
849
+ min_employees: Optional[int] = Field(
850
+ default=None,
851
+ description="Minimum number of employees (>=1). Internally converted to a numeric range."
852
+ )
853
+
854
+ max_employees: Optional[int] = Field(
855
+ default=None,
856
+ description="Maximum number of employees (<=100000). Internally converted to a numeric range."
857
+ )
858
+
859
+ organization_industries: Optional[List[str]] = Field(
860
+ default=None,
861
+ description="List of organization industries."
862
+ )
863
+
864
+ organization_industry_tag_ids: Optional[List[str]] = Field(
865
+ default=None,
866
+ description="List of industry tag IDs, e.g. ['5567cd4773696439b10b0000']."
867
+ )
868
+
869
+ # Revenue filters
870
+ revenue_range_min: Optional[int] = Field(
871
+ default=None,
872
+ description="Minimum company revenue in USD."
873
+ )
874
+
875
+ revenue_range_max: Optional[int] = Field(
876
+ default=None,
877
+ description="Maximum company revenue in USD."
878
+ )
879
+
880
+ # Funding and growth
881
+ organization_latest_funding_stage_cd: Optional[List[str]] = Field(
882
+ default=None,
883
+ description="List of funding stage codes, e.g. ['2', '3', '10']."
884
+ )
885
+
886
+ # Technology and keywords
887
+ currently_using_any_of_technology_uids: Optional[List[str]] = Field(
888
+ default=None,
889
+ description="Technology UIDs used by the organization, e.g. ['google_font_api']."
890
+ )
891
+
892
+ q_keywords: Optional[str] = Field(
893
+ default=None,
894
+ description="Keywords to search for in company descriptions, names, etc."
895
+ )
896
+
897
+ q_organization_domains: Optional[List[str]] = Field(
898
+ default=None,
899
+ description="Specific company domains to search for, e.g. ['microsoft.com', 'google.com']."
900
+ )
901
+
902
+ # Company-specific filters
903
+ organization_ids: Optional[List[str]] = Field(
904
+ default=None,
905
+ description="Specific Apollo organization IDs to include."
906
+ )
907
+
908
+ not_organization_ids: Optional[List[str]] = Field(
909
+ default=None,
910
+ description="Apollo organization IDs to exclude from results."
911
+ )
912
+
913
+ # Search lists
914
+ q_organization_search_list_id: Optional[str] = Field(
915
+ default=None,
916
+ description="Include only organizations in a specific search list."
917
+ )
918
+
919
+ q_not_organization_search_list_id: Optional[str] = Field(
920
+ default=None,
921
+ description="Exclude organizations in a specific search list."
922
+ )
923
+
924
+ # Sorting
925
+ sort_by_field: Optional[str] = Field(
926
+ default=None,
927
+ description="Sort field, e.g. 'name', 'employee_count', 'last_updated', etc."
928
+ )
929
+
930
+ sort_ascending: Optional[bool] = Field(
931
+ default=None,
932
+ description="Sort ascending (True) or descending (False)."
933
+ )
934
+
935
+ # Additional filters that might be useful
936
+ organization_founded_year_min: Optional[int] = Field(
937
+ default=None,
938
+ description="Minimum founding year for the organization."
939
+ )
940
+
941
+ organization_founded_year_max: Optional[int] = Field(
942
+ default=None,
943
+ description="Maximum founding year for the organization."
944
+ )
@@ -1,10 +1,7 @@
1
1
  import hashlib
2
- from urllib.parse import urlparse
3
2
  from typing import List, Optional
4
- import hashlib
5
3
  import logging
6
4
  from typing import Optional, Dict, Any
7
- from pydantic import ValidationError
8
5
  from dhisana.schemas.sales import MessageItem
9
6
  from dhisana.utils.cache_output_tools import (
10
7
  retrieve_output,
@@ -12,29 +9,38 @@ from dhisana.utils.cache_output_tools import (
12
9
  )
13
10
  from dhisana.utils.field_validators import normalize_linkedin_url, normalize_salesnav_url
14
11
  from dhisana.utils.parse_linkedin_messages_txt import parse_conversation
15
- logger = logging.getLogger(__name__)
16
12
 
13
+ logger = logging.getLogger(__name__)
17
14
 
15
+ def get_short_id_from_salesnav_url(sn_url: str) -> str:
16
+ import re
17
+ if not sn_url:
18
+ return ""
19
+ if "/sales/lead/" in sn_url:
20
+ pattern = r"linkedin\.com/sales/lead/([^/?#,]+)"
21
+ match = re.search(pattern, sn_url, re.IGNORECASE)
22
+ if not match:
23
+ return ""
24
+ sales_nav_id = re.sub(r"[^\w-]", "", match.group(1))
25
+ # Arbitrary example logic: if it starts with ACw or ACo, strip those and return 8 chars
26
+ if sales_nav_id.startswith("ACw") or sales_nav_id.startswith("ACo"):
27
+ return sales_nav_id[3:11]
28
+ return ""
18
29
 
19
30
  async def add_mapping_tool(mapping: dict) -> dict:
20
31
  """
21
32
  Create a two-way (forward & reverse) cache mapping between:
22
33
  - user_linkedin_url (normalized)
23
- - user_linkedin_salesnav_url (normalized)
24
- AND store single-direction entries for easy lookup:
25
- - LN→SN (key: "mapping_ln:<sha_of_ln>")
26
- - SN→LN (key: "mapping_sn:<sha_of_sn>")
34
+ - user_linkedin_salesnav_url (normalized, with short ID extracted if possible)
35
+
36
+ Also store single-direction entries for easy lookup:
37
+ - LN→SN (key: "mapping_ln:<sha_of_normalized_ln>")
38
+ - SN→LN (key: "mapping_sn:<sha_of_short_id_or_full_sn>")
27
39
 
28
40
  The cache content has the actual (raw) user-provided URLs,
29
41
  only the keys are lowercased/hashed.
30
42
 
31
43
  Returns a dict with status, message, and data.
32
-
33
- Example 'mapping':
34
- {
35
- "user_linkedin_url": "linkedin.com/in/some-user/",
36
- "user_linkedin_salesnav_url": "https://www.linkedin.com/sales/lead/123456,NAME_SEARCH"
37
- }
38
44
  """
39
45
  user_linkedin_url = mapping.get("user_linkedin_url", "").strip()
40
46
  user_linkedin_salesnav_url = mapping.get("user_linkedin_salesnav_url", "").strip()
@@ -48,16 +54,19 @@ async def add_mapping_tool(mapping: dict) -> dict:
48
54
  # Normalize
49
55
  ln_url_norm = normalize_linkedin_url(user_linkedin_url)
50
56
  sn_url_norm = normalize_salesnav_url(user_linkedin_salesnav_url)
57
+ salesnav_short_id = get_short_id_from_salesnav_url(sn_url_norm)
58
+
59
+ # We'll use short_id in the forward/reverse *mapping strings*
60
+ forward_str = f"{ln_url_norm}→{salesnav_short_id}"
61
+ reverse_str = f"{salesnav_short_id}→{ln_url_norm}"
51
62
 
52
- # Forward & reverse combined keys (to check if identical mapping was previously stored)
53
- forward_str = f"{ln_url_norm}→{sn_url_norm}"
54
- reverse_str = f"{sn_url_norm}→{ln_url_norm}"
55
63
  forward_key = "mapping:" + hashlib.sha256(forward_str.encode("utf-8")).hexdigest()
56
64
  reverse_key = "mapping:" + hashlib.sha256(reverse_str.encode("utf-8")).hexdigest()
57
65
 
58
- # Check forward mapping
59
- forward_cached = retrieve_output("tool_mappings", forward_key)
66
+ # Use the same namespace used later by the single-direction logic
67
+ forward_cached = retrieve_output("tool_mappings_linkedin_id", forward_key)
60
68
  if forward_cached:
69
+ # If we found an identical forward mapping, return success
61
70
  if (
62
71
  forward_cached.get("user_linkedin_url") == ln_url_norm
63
72
  and forward_cached.get("user_linkedin_salesnav_url") == sn_url_norm
@@ -68,12 +77,12 @@ async def add_mapping_tool(mapping: dict) -> dict:
68
77
  "data": forward_cached
69
78
  }
70
79
 
71
- # Check reverse mapping
72
- reverse_cached = retrieve_output("tool_mappings", reverse_key)
80
+ reverse_cached = retrieve_output("tool_mappings_linkedin_id", reverse_key)
73
81
  if reverse_cached:
82
+ # If we found an identical reverse mapping, return success
74
83
  if (
75
84
  reverse_cached.get("user_linkedin_url") == ln_url_norm
76
- and reverse_cached.get("user_linkedin_salesnav_url") == sn_url_norm
85
+ and reverse_cached.get("salesnav_short_id") == salesnav_short_id
77
86
  ):
78
87
  return {
79
88
  "status": "SUCCESS",
@@ -84,34 +93,36 @@ async def add_mapping_tool(mapping: dict) -> dict:
84
93
  # Create object for storing in forward & reverse
85
94
  serialized_results = {
86
95
  "user_linkedin_url": ln_url_norm,
87
- "user_linkedin_salesnav_url": sn_url_norm
96
+ "user_linkedin_salesnav_url": sn_url_norm,
97
+ "salesnav_short_id": salesnav_short_id
88
98
  }
89
99
 
90
- # Store them
91
- cache_output("tool_mappings", forward_key, serialized_results)
92
- cache_output("tool_mappings", reverse_key, serialized_results)
100
+ # Store them in the same place we retrieve them from
101
+ cache_output("tool_mappings_linkedin_id", forward_key, serialized_results)
102
+ cache_output("tool_mappings_linkedin_id", reverse_key, serialized_results)
93
103
 
94
104
  # ------------------------------------------------------------------------
95
- # Additional single-direction keys to enable easy lookups (raw usage):
96
- # LN key: "mapping_ln:<sha_of_normalized_ln.lower()>"
97
- # SN key: "mapping_sn:<sha_of_normalized_sn.lower()>"
98
- # And store the *raw* original user URLs as requested
105
+ # Additional single-direction keys to enable easy lookups:
106
+ # LN key: "mapping_ln:<sha_of_normalized_ln>"
107
+ # SN key: "mapping_sn:<sha_of_short_id_or_full_sn>"
99
108
  # ------------------------------------------------------------------------
100
109
  ln_lower = ln_url_norm.lower()
101
- sn_lower = sn_url_norm.lower()
102
110
 
111
+ # For SN, we prefer the short ID if it's not empty; otherwise fall back to the full URL.
112
+ sn_lookup_value = salesnav_short_id if salesnav_short_id else sn_url_norm
103
113
  ln_key = "mapping_ln:" + hashlib.sha256(ln_lower.encode("utf-8")).hexdigest()
104
- sn_key = "mapping_sn:" + hashlib.sha256(sn_lower.encode("utf-8")).hexdigest()
114
+ sn_key = "mapping_sn:" + hashlib.sha256(sn_lookup_value.lower().encode("utf-8")).hexdigest()
105
115
 
106
- # Cache the raw user-provided URLs
116
+ # Cache the raw user-provided (normalized) URLs
107
117
  single_direction_data = {
108
- "raw_linkedin_url": ln_url_norm, # no lowercasing
109
- "raw_salesnav_url": sn_url_norm, # no lowercasing
118
+ "raw_linkedin_url": ln_url_norm, # no forced lowercasing in the stored data
119
+ "raw_salesnav_url": sn_url_norm,
120
+ "salesnav_short_id": salesnav_short_id
110
121
  }
111
122
  logger.info("mapping data cached: %s", single_direction_data)
112
123
 
113
- cache_output("tool_mappings", ln_key, single_direction_data)
114
- cache_output("tool_mappings", sn_key, single_direction_data)
124
+ cache_output("tool_mappings_linkedin_id", ln_key, single_direction_data)
125
+ cache_output("tool_mappings_linkedin_id", sn_key, single_direction_data)
115
126
 
116
127
  return {
117
128
  "status": "SUCCESS",
@@ -119,43 +130,42 @@ async def add_mapping_tool(mapping: dict) -> dict:
119
130
  "data": serialized_results
120
131
  }
121
132
 
122
-
123
133
  async def get_salesnav_url_for_linkedin_url(raw_ln_url: str) -> Optional[str]:
124
134
  """
125
135
  Given a LinkedIn URL, normalize it and look up the corresponding
126
136
  *raw* sales nav URL from the single-direction cache.
127
- Return None if not found in cache.
137
+ Returns None if not found.
128
138
  """
129
- # Normalize & lower-case for key
130
- ln_norm = normalize_linkedin_url(raw_ln_url)
131
- ln_key = "mapping_ln:" + hashlib.sha256(ln_norm.lower().encode("utf-8")).hexdigest()
139
+ ln_norm = normalize_linkedin_url(raw_ln_url).lower()
140
+ ln_key = "mapping_ln:" + hashlib.sha256(ln_norm.encode("utf-8")).hexdigest()
132
141
 
133
- record = retrieve_output("tool_mappings", ln_key)
142
+ # Must retrieve from the same namespace we used when caching
143
+ record = retrieve_output("tool_mappings_linkedin_id", ln_key)
134
144
  if not record:
135
145
  return None
136
146
 
137
- # record["raw_salesnav_url"] is the original user_linkedin_salesnav_url
138
147
  return record.get("raw_salesnav_url")
139
148
 
140
-
141
149
  async def get_linkedin_url_for_salesnav_url(raw_sn_url: str) -> Optional[str]:
142
150
  """
143
151
  Given a Sales Navigator URL, normalize it and look up the corresponding
144
152
  *raw* LinkedIn URL from the single-direction cache.
145
- Return None if not found in cache.
153
+ Uses the short ID if available; otherwise falls back to the full SN URL.
154
+ Returns None if not found in cache.
146
155
  """
147
- # Normalize & lower-case for key
148
156
  sn_norm = normalize_salesnav_url(raw_sn_url)
149
- sn_key = "mapping_sn:" + hashlib.sha256(sn_norm.lower().encode("utf-8")).hexdigest()
157
+ salesnav_short_id = get_short_id_from_salesnav_url(sn_norm)
150
158
 
151
- record = retrieve_output("tool_mappings", sn_key)
159
+ # Use short ID if present, else the normalized URL
160
+ sn_lookup_value = salesnav_short_id if salesnav_short_id else sn_norm
161
+ sn_key = "mapping_sn:" + hashlib.sha256(sn_lookup_value.lower().encode("utf-8")).hexdigest()
162
+
163
+ record = retrieve_output("tool_mappings_linkedin_id", sn_key)
152
164
  if not record:
153
165
  return None
154
166
 
155
- # record["raw_linkedin_url"] is the original user_linkedin_url
156
167
  return record.get("raw_linkedin_url")
157
168
 
158
-
159
169
  async def cache_enriched_lead_info_from_salesnav(lead_info: dict, agent_id: str) -> Dict[str, Any]:
160
170
  """
161
171
  Cache lead information using the old SHA-256 approach if and only if:
@@ -263,7 +273,6 @@ async def retrieve_lead_html_from_salesnav(user_linkedin_url: str, agent_id: str
263
273
 
264
274
  return retrieve_output("tool_mappings", sn_key)
265
275
 
266
-
267
276
  async def cache_touchpoint_status(
268
277
  lead_info: Dict[str, Any],
269
278
  agent_id: str,
@@ -272,9 +281,8 @@ async def cache_touchpoint_status(
272
281
  """
273
282
  Cache TouchPointStatus data for a given lead.
274
283
  The cache key is generated from the SHA-256 hash of the normalized LinkedIn URL
275
- and the sdr_user_id if available.
284
+ plus the agent_id.
276
285
  """
277
- # ...existing code...
278
286
  if not lead_info or not lead_info.get("user_linkedin_url"):
279
287
  return {
280
288
  "status": "ERROR",
@@ -283,7 +291,6 @@ async def cache_touchpoint_status(
283
291
 
284
292
  user_linkedin_url = lead_info["user_linkedin_url"].strip()
285
293
  sn_norm = normalize_linkedin_url(user_linkedin_url)
286
-
287
294
  sn_key = "touchpoint_status:" + hashlib.sha256((sn_norm.lower() + agent_id).encode("utf-8")).hexdigest()
288
295
 
289
296
  cache_output("touchpoint_status", sn_key, touchpoint_data)
@@ -300,9 +307,8 @@ async def retrieve_touchpoint_status(
300
307
  ):
301
308
  """
302
309
  Retrieve TouchPointStatus data from the cache for a given user LinkedIn URL
303
- and sdr_user_id if available.
310
+ and agent_id.
304
311
  """
305
- # ...existing code...
306
312
  sn_norm = normalize_linkedin_url(user_linkedin_url.strip())
307
313
  sn_key = "touchpoint_status:" + hashlib.sha256((sn_norm.lower() + agent_id).encode("utf-8")).hexdigest()
308
314
 
@@ -313,14 +319,16 @@ async def retrieve_connection_status(
313
319
  user_linkedin_url: str,
314
320
  agent_id: str
315
321
  ):
316
- # ...existing code...
322
+ """
323
+ Retrieve minimal "connection status" from the TouchPointStatus cache.
324
+ """
317
325
  sn_norm = normalize_linkedin_url(user_linkedin_url.strip())
318
326
  sn_key = "touchpoint_status:" + hashlib.sha256((sn_norm.lower() + agent_id).encode("utf-8")).hexdigest()
319
327
 
320
328
  cached_data = retrieve_output("touchpoint_status", sn_key) or {}
321
329
  connection_degree = cached_data.get("connection_degree", "")
322
330
  connection_status = {
323
- "connection_degree": cached_data.get("connection_degree", ""),
331
+ "connection_degree": connection_degree,
324
332
  "connection_request_status": cached_data.get("connection_request_status", ""),
325
333
  "is_connected_on_linkedin": connection_degree == "1st"
326
334
  }
@@ -332,12 +340,13 @@ async def get_lead_linkedin_messages(user_linkedin_url: str, agent_id: str) -> O
332
340
  The cache key includes agent_id in the SHA-256 hash.
333
341
  """
334
342
  sn_norm = normalize_linkedin_url(user_linkedin_url)
335
- sn_key = "salesnav_lead_messages_raw:" + hashlib.sha256((sn_norm + agent_id).encode("utf-8")).hexdigest()
336
- list_of_dicts = retrieve_output("lead_linkedin_messages_", sn_key)
337
- messages: List[MessageItem] = []
343
+ sn_key = "salesnav_lead_messages_raw:" + hashlib.sha256(
344
+ (sn_norm + agent_id).encode("utf-8")
345
+ ).hexdigest()
338
346
 
347
+ list_of_dicts = retrieve_output("lead_linkedin_messages_", sn_key) or []
348
+ messages: List[MessageItem] = []
339
349
  for message in list_of_dicts:
340
350
  message_item = MessageItem(**message)
341
351
  messages.append(message_item)
342
-
343
352
  return messages