dhisana 0.0.1.dev305__tar.gz → 0.0.1.dev307__tar.gz

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 (127) hide show
  1. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/setup.py +1 -1
  3. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/schemas/sales.py +2 -0
  4. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_content.py +3 -0
  5. dhisana-0.0.1.dev307/src/dhisana/utils/generate_sms_whatsapp.py +260 -0
  6. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana.egg-info/PKG-INFO +1 -1
  7. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana.egg-info/SOURCES.txt +2 -0
  8. dhisana-0.0.1.dev307/tests/test_generate_sms_whatsapp.py +260 -0
  9. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/README.md +0 -0
  10. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/pyproject.toml +0 -0
  11. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/setup.cfg +0 -0
  12. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/__init__.py +0 -0
  13. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/cli/__init__.py +0 -0
  14. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/cli/cli.py +0 -0
  15. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/cli/datasets.py +0 -0
  16. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/cli/models.py +0 -0
  17. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/cli/predictions.py +0 -0
  18. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/schemas/__init__.py +0 -0
  19. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/schemas/common.py +0 -0
  20. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/ui/__init__.py +0 -0
  21. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/ui/components.py +0 -0
  22. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/__init__.py +0 -0
  23. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/add_mapping.py +0 -0
  24. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/agent_tools.py +0 -0
  25. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/apollo_tools.py +0 -0
  26. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  27. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/built_with_api_tools.py +0 -0
  28. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/cache_output_tools.py +0 -0
  29. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  30. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  31. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  32. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  33. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/clay_tools.py +0 -0
  34. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/clean_properties.py +0 -0
  35. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/company_utils.py +0 -0
  36. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  37. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/compose_search_query.py +0 -0
  38. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  39. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/composite_tools.py +0 -0
  40. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/dataframe_tools.py +0 -0
  41. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/domain_parser.py +0 -0
  42. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/email_body_utils.py +0 -0
  43. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/email_parse_helpers.py +0 -0
  44. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/email_provider.py +0 -0
  45. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/enrich_lead_information.py +0 -0
  46. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  47. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/fetch_openai_config.py +0 -0
  48. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/field_validators.py +0 -0
  49. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/g2_tools.py +0 -0
  50. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_custom_message.py +0 -0
  51. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_email.py +0 -0
  52. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_email_response.py +0 -0
  53. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_flow.py +0 -0
  54. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  55. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  56. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  57. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
  58. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/google_custom_search.py +0 -0
  59. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/google_oauth_tools.py +0 -0
  60. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/google_workspace_tools.py +0 -0
  61. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  62. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  63. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/instantly_tools.py +0 -0
  64. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/linkedin_crawler.py +0 -0
  65. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/lusha_tools.py +0 -0
  66. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/mailgun_tools.py +0 -0
  67. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/mailreach_tools.py +0 -0
  68. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/microsoft365_tools.py +0 -0
  69. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  70. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/openai_helpers.py +0 -0
  71. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  72. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  73. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  74. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  75. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  76. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  77. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/profile.py +0 -0
  78. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  79. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  80. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/python_function_to_tools.py +0 -0
  81. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/research_lead.py +0 -0
  82. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  83. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  84. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/search_router.py +0 -0
  85. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/search_router_jobs.py +0 -0
  86. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/sendgrid_tools.py +0 -0
  87. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  88. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  89. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  90. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serpapi_google_search.py +0 -0
  91. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  92. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serpapi_search_tools.py +0 -0
  93. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  94. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serperdev_local_business.py +0 -0
  95. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/serperdev_search.py +0 -0
  96. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/smtp_email_tools.py +0 -0
  97. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/test_connect.py +0 -0
  98. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/trasform_json.py +0 -0
  99. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  100. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/workflow_code_model.py +0 -0
  101. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/utils/zoominfo_tools.py +0 -0
  102. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/workflow/__init__.py +0 -0
  103. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/workflow/agent.py +0 -0
  104. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/workflow/flow.py +0 -0
  105. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/workflow/task.py +0 -0
  106. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana/workflow/test.py +0 -0
  107. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana.egg-info/dependency_links.txt +0 -0
  108. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana.egg-info/entry_points.txt +0 -0
  109. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana.egg-info/requires.txt +0 -0
  110. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/src/dhisana.egg-info/top_level.txt +0 -0
  111. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_agent_tools.py +0 -0
  112. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_apollo_company_search.py +0 -0
  113. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_apollo_lead_search.py +0 -0
  114. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_connectivity.py +0 -0
  115. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_email_body_utils.py +0 -0
  116. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_generate_email.py +0 -0
  117. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_google_document.py +0 -0
  118. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_hubspot_call_logs.py +0 -0
  119. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_linkedin_serper.py +0 -0
  120. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_mailreach.py +0 -0
  121. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_mcp_connectivity.py +0 -0
  122. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_normalize_graph_datetime.py +0 -0
  123. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_proxycurl_get_company_search_id.py +0 -0
  124. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_proxycurl_job_count.py +0 -0
  125. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_reply_thread_fallback.py +0 -0
  126. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_send_email_recipients.py +0 -0
  127. {dhisana-0.0.1.dev305 → dhisana-0.0.1.dev307}/tests/test_structured_output_with_mcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev305
3
+ Version: 0.0.1.dev307
4
4
  Summary: A Python SDK for Dhisana AI Platform
5
5
  Home-page: https://github.com/dhisana-ai/dhisana-python-sdk
6
6
  Author: Admin
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='dhisana',
5
- version='0.0.1-dev305',
5
+ version='0.0.1-dev307',
6
6
  description='A Python SDK for Dhisana AI Platform',
7
7
  author='Admin',
8
8
  author_email='contact@dhisana.ai',
@@ -271,6 +271,8 @@ class ChannelType(str, Enum):
271
271
  REPLY_EMAIL = "reply_email"
272
272
  LINKEDIN_USER_MESSAGE = "linkedin_user_message"
273
273
  CUSTOM_MESSAGE = "custom_message"
274
+ SMS = "sms"
275
+ WHATSAPP = "whatsapp"
274
276
 
275
277
  class SenderInfo(BaseModel):
276
278
  """
@@ -6,6 +6,7 @@ from dhisana.utils.assistant_tool_tag import assistant_tool
6
6
  from dhisana.utils.generate_email import generate_personalized_email
7
7
  from dhisana.utils.generate_linkedin_response_message import get_linkedin_response_message_variations
8
8
  from dhisana.utils.generate_custom_message import generate_custom_message
9
+ from dhisana.utils.generate_sms_whatsapp import generate_sms_whatsapp_message
9
10
 
10
11
 
11
12
  @assistant_tool
@@ -36,6 +37,8 @@ async def generate_content(
36
37
  return await generate_inbound_email_response_variations(generation_context, number_of_variations, tool_config)
37
38
  elif generation_context.target_channel_type == ChannelType.LINKEDIN_USER_MESSAGE.value:
38
39
  return await get_linkedin_response_message_variations(generation_context, number_of_variations, tool_config)
40
+ elif generation_context.target_channel_type in (ChannelType.SMS.value, ChannelType.WHATSAPP.value):
41
+ return await generate_sms_whatsapp_message(generation_context, number_of_variations, tool_config)
39
42
  else:
40
43
  # Default to CUSTOM_MESSAGE for any unrecognized channel type
41
44
  return await generate_custom_message(generation_context, number_of_variations, tool_config)
@@ -0,0 +1,260 @@
1
+ import re
2
+ from datetime import datetime, timezone
3
+ from typing import Dict, List, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from dhisana.schemas.sales import (
8
+ CampaignContext,
9
+ ChannelType,
10
+ ContentGenerationContext,
11
+ ConversationContext,
12
+ Lead,
13
+ MessageGenerationInstructions,
14
+ MessageItem,
15
+ SenderInfo,
16
+ )
17
+ from dhisana.utils.generate_email import _sanitize_lead_for_prompt
18
+ from dhisana.utils.generate_structured_output_internal import (
19
+ get_structured_output_internal,
20
+ get_structured_output_with_assistant_and_vector_store,
21
+ )
22
+
23
+ DEFAULT_SMS_GEN_MODEL = "gpt-5.4"
24
+
25
+ SMS_CHAR_LIMIT = 160
26
+ WHATSAPP_CHAR_LIMIT = 1000
27
+
28
+ # Standard opt-out suffix appended to every generated message.
29
+ OPT_OUT_SUFFIX_SMS = "\nReply STOP to opt out."
30
+ OPT_OUT_SUFFIX_WHATSAPP = "\nReply STOP to opt out."
31
+
32
+
33
+ class SMSWhatsAppCopy(BaseModel):
34
+ body: str
35
+
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+
39
+ def _effective_char_limit(channel_type: ChannelType) -> int:
40
+ if channel_type == ChannelType.WHATSAPP:
41
+ return WHATSAPP_CHAR_LIMIT
42
+ return SMS_CHAR_LIMIT
43
+
44
+
45
+ def _opt_out_suffix(channel_type: ChannelType) -> str:
46
+ if channel_type == ChannelType.WHATSAPP:
47
+ return OPT_OUT_SUFFIX_WHATSAPP
48
+ return OPT_OUT_SUFFIX_SMS
49
+
50
+
51
+ # Matches explicit opt-out instructions like "Reply STOP", "Text STOP to …",
52
+ # "opt out", or "unsubscribe" — requires an action verb before "stop" so
53
+ # normal prose like "stop wasting time" does not suppress the footer.
54
+ _OPT_OUT_RE = re.compile(
55
+ r"(?:reply|text|send|message|say)\s+stop\b"
56
+ r"|\bopt[\s\-]?out\b"
57
+ r"|\bunsubscribe\b",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def _ensure_opt_out(body: str, channel_type: ChannelType) -> str:
63
+ suffix = _opt_out_suffix(channel_type)
64
+ if _OPT_OUT_RE.search(body):
65
+ return body
66
+ return body.rstrip() + suffix
67
+
68
+
69
+ def _cleanup_context(
70
+ ctx: ContentGenerationContext,
71
+ ) -> ContentGenerationContext:
72
+ clone = ctx.model_copy(deep=True)
73
+ if clone.external_known_data:
74
+ clone.external_known_data.external_openai_vector_store_id = None
75
+ return clone
76
+
77
+
78
+ async def generate_sms_whatsapp_copy(
79
+ message_context: ContentGenerationContext,
80
+ message_instructions: MessageGenerationInstructions,
81
+ variation_text: str,
82
+ channel_type: ChannelType,
83
+ tool_config: Optional[List[Dict]] = None,
84
+ ) -> dict:
85
+ cleaned = _cleanup_context(message_context)
86
+
87
+ user_instructions = (
88
+ message_instructions.instructions_to_generate_message or ""
89
+ ).strip()
90
+ selected_instructions = user_instructions if user_instructions else variation_text
91
+
92
+ lead_data = cleaned.lead_info or Lead()
93
+ sender_data = cleaned.sender_info or SenderInfo()
94
+ campaign_data = cleaned.campaign_context or CampaignContext()
95
+ conversation_data = (
96
+ cleaned.current_conversation_context or ConversationContext()
97
+ )
98
+
99
+ char_limit = _effective_char_limit(channel_type)
100
+ opt_out_suffix = _opt_out_suffix(channel_type)
101
+ usable_chars = char_limit - len(opt_out_suffix)
102
+ channel_label = "WhatsApp" if channel_type == ChannelType.WHATSAPP else "SMS"
103
+
104
+ prompt = f"""You are an AI sales copywriter. Your task is to write exactly one personalized outbound business {channel_label} text message on behalf of the sender to the lead described below.
105
+
106
+ This is a legitimate B2B sales outreach message. The sender has opted in to sending these messages through our platform.
107
+
108
+ ## Channel Rules — {channel_label}
109
+
110
+ 1. The message body (excluding the compliance footer) MUST be {usable_chars} characters or fewer.
111
+ 2. Plain text only — no HTML, no markdown, no emojis unless the instructions ask for them.
112
+ 3. No subject line — output only a "body" field.
113
+ 4. Be concise and conversational. Lead with value, not a greeting. Avoid filler.
114
+ 5. Do NOT include any opt-out / STOP language — the system appends it automatically.
115
+ 6. Do NOT include phone numbers, email addresses, or links unless the writing instructions explicitly request a specific provided URL.
116
+ 7. Ground every claim in the provided context. When information is missing, omit it — never guess or fabricate.
117
+ 8. Do not use em dashes.
118
+ 9. The "body" field must contain ONLY the message text. Never append JSON, metadata, or structured data to the body.
119
+
120
+ ## Lead
121
+
122
+ Name: {lead_data.first_name or ''} {lead_data.last_name or ''}
123
+ Organization: {lead_data.organization_name or ''}
124
+ Title: {lead_data.job_title or ''}
125
+
126
+ ## Sender (closed set — use ONLY these facts)
127
+
128
+ - Full Name: {sender_data.sender_full_name or ''}
129
+ - First Name: {sender_data.sender_first_name or ''}
130
+ - Last Name: {sender_data.sender_last_name or ''}
131
+ - Bio: {sender_data.sender_bio or ''}
132
+
133
+ ## Campaign
134
+
135
+ - Product: {campaign_data.product_name or ''}
136
+ - Value Proposition: {campaign_data.value_prop or ''}
137
+ - Call to Action: {campaign_data.call_to_action or ''}
138
+ - Pain Points: {campaign_data.pain_points or []}
139
+ - Proof Points: {campaign_data.proof_points or []}
140
+
141
+ ## Writing Instructions
142
+
143
+ {selected_instructions}
144
+
145
+ ## Conversation History
146
+
147
+ {conversation_data.current_email_thread or ''}
148
+ {conversation_data.current_linkedin_thread or ''}
149
+
150
+ ## Output Format
151
+
152
+ Return JSON with a single field: "body" (string, plain text, max {usable_chars} chars).
153
+
154
+ ## Additional Lead Context (reference only)
155
+
156
+ {_sanitize_lead_for_prompt(lead_data)}
157
+ """
158
+
159
+ vector_store_id = (
160
+ message_context.external_known_data.external_openai_vector_store_id
161
+ if message_context.external_known_data
162
+ else None
163
+ )
164
+ use_cache = (
165
+ message_context.message_instructions.use_cache
166
+ if message_context.message_instructions
167
+ else True
168
+ )
169
+
170
+ if vector_store_id:
171
+ response, status = (
172
+ await get_structured_output_with_assistant_and_vector_store(
173
+ prompt=prompt,
174
+ response_format=SMSWhatsAppCopy,
175
+ vector_store_id=vector_store_id,
176
+ model=DEFAULT_SMS_GEN_MODEL,
177
+ effort="medium",
178
+ tool_config=tool_config,
179
+ use_cache=use_cache,
180
+ )
181
+ )
182
+ else:
183
+ response, status = await get_structured_output_internal(
184
+ prompt=prompt,
185
+ response_format=SMSWhatsAppCopy,
186
+ model=DEFAULT_SMS_GEN_MODEL,
187
+ effort="medium",
188
+ tool_config=tool_config,
189
+ use_cache=use_cache,
190
+ )
191
+
192
+ if status != "SUCCESS":
193
+ # Include the refusal/error detail if the model returned text
194
+ detail = ""
195
+ if isinstance(response, str) and response:
196
+ detail = f" Model response: {response[:200]}"
197
+ raise Exception(
198
+ f"Error: Could not generate {channel_label} message.{detail}"
199
+ )
200
+
201
+ body = (response.body or "").strip()
202
+ # Truncate if model exceeded the limit
203
+ if len(body) > usable_chars:
204
+ body = body[:usable_chars].rsplit(" ", 1)[0]
205
+ body = _ensure_opt_out(body, channel_type)
206
+
207
+ response_item = MessageItem(
208
+ message_id="",
209
+ thread_id="",
210
+ sender_name=sender_data.sender_full_name or "",
211
+ sender_email=sender_data.sender_email or "",
212
+ receiver_name=(message_context.lead_info.full_name or "") if message_context.lead_info else "",
213
+ receiver_email=(message_context.lead_info.email or "") if message_context.lead_info else "",
214
+ iso_datetime=datetime.now(timezone.utc).isoformat(),
215
+ subject="",
216
+ body=body,
217
+ html_body=None,
218
+ )
219
+ return response_item.model_dump()
220
+
221
+
222
+ FRAMEWORK_VARIATIONS_SMS = [
223
+ "Write a brief, personalized outreach message. Lead with a relevant insight about the prospect's company.",
224
+ "Write a short value-first message — mention one pain point and how the product helps.",
225
+ "Write a concise, curiosity-driven message with a clear call to action.",
226
+ ]
227
+
228
+
229
+ async def generate_sms_whatsapp_message(
230
+ generation_context: ContentGenerationContext,
231
+ number_of_variations: int = 3,
232
+ tool_config: Optional[List[Dict]] = None,
233
+ ) -> List[dict]:
234
+ channel_type = generation_context.target_channel_type or ChannelType.SMS
235
+ message_instructions = generation_context.message_instructions
236
+ user_instructions_exist = bool(
237
+ (message_instructions.instructions_to_generate_message or "").strip()
238
+ )
239
+
240
+ variations: List[dict] = []
241
+ for i in range(number_of_variations):
242
+ if user_instructions_exist:
243
+ variation_text = (
244
+ message_instructions.instructions_to_generate_message or ""
245
+ )
246
+ else:
247
+ variation_text = FRAMEWORK_VARIATIONS_SMS[
248
+ i % len(FRAMEWORK_VARIATIONS_SMS)
249
+ ]
250
+
251
+ copy = await generate_sms_whatsapp_copy(
252
+ message_context=generation_context,
253
+ message_instructions=message_instructions,
254
+ variation_text=variation_text,
255
+ channel_type=channel_type,
256
+ tool_config=tool_config,
257
+ )
258
+ variations.append(copy)
259
+
260
+ return variations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev305
3
+ Version: 0.0.1.dev307
4
4
  Summary: A Python SDK for Dhisana AI Platform
5
5
  Home-page: https://github.com/dhisana-ai/dhisana-python-sdk
6
6
  Author: Admin
@@ -54,6 +54,7 @@ src/dhisana/utils/generate_flow.py
54
54
  src/dhisana/utils/generate_leads_salesnav.py
55
55
  src/dhisana/utils/generate_linkedin_connect_message.py
56
56
  src/dhisana/utils/generate_linkedin_response_message.py
57
+ src/dhisana/utils/generate_sms_whatsapp.py
57
58
  src/dhisana/utils/generate_structured_output_internal.py
58
59
  src/dhisana/utils/google_custom_search.py
59
60
  src/dhisana/utils/google_oauth_tools.py
@@ -110,6 +111,7 @@ tests/test_apollo_lead_search.py
110
111
  tests/test_connectivity.py
111
112
  tests/test_email_body_utils.py
112
113
  tests/test_generate_email.py
114
+ tests/test_generate_sms_whatsapp.py
113
115
  tests/test_google_document.py
114
116
  tests/test_hubspot_call_logs.py
115
117
  tests/test_linkedin_serper.py
@@ -0,0 +1,260 @@
1
+ import asyncio
2
+ import unittest
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ from dhisana.schemas.sales import (
6
+ CampaignContext,
7
+ ChannelType,
8
+ ContentGenerationContext,
9
+ ConversationContext,
10
+ Lead,
11
+ MessageGenerationInstructions,
12
+ SenderInfo,
13
+ )
14
+ from dhisana.utils.generate_sms_whatsapp import (
15
+ OPT_OUT_SUFFIX_SMS,
16
+ OPT_OUT_SUFFIX_WHATSAPP,
17
+ SMS_CHAR_LIMIT,
18
+ SMSWhatsAppCopy,
19
+ WHATSAPP_CHAR_LIMIT,
20
+ _effective_char_limit,
21
+ _ensure_opt_out,
22
+ generate_sms_whatsapp_copy,
23
+ generate_sms_whatsapp_message,
24
+ )
25
+
26
+
27
+ def _build_context(channel: ChannelType = ChannelType.SMS) -> ContentGenerationContext:
28
+ lead = Lead(
29
+ full_name="Jane Smith",
30
+ first_name="Jane",
31
+ last_name="Smith",
32
+ email="jane@acmecorp.com",
33
+ job_title="VP of Engineering",
34
+ organization_name="Acme Corp",
35
+ phone="404-555-1234",
36
+ )
37
+ sender = SenderInfo(
38
+ sender_full_name="John Doe",
39
+ sender_first_name="John",
40
+ sender_last_name="Doe",
41
+ sender_email="john@dhisana.ai",
42
+ sender_bio="Co-founder at Dhisana",
43
+ )
44
+ campaign = CampaignContext(
45
+ product_name="Dhisana AI",
46
+ value_prop="AI-powered sales outreach",
47
+ call_to_action="Book a demo",
48
+ )
49
+ instructions = MessageGenerationInstructions(
50
+ instructions_to_generate_message="Write a brief intro message.",
51
+ use_cache=False,
52
+ allow_html=False,
53
+ )
54
+ return ContentGenerationContext(
55
+ lead_info=lead,
56
+ sender_info=sender,
57
+ campaign_context=campaign,
58
+ current_conversation_context=ConversationContext(),
59
+ target_channel_type=channel,
60
+ message_instructions=instructions,
61
+ )
62
+
63
+
64
+ class TestCharLimits(unittest.TestCase):
65
+ def test_sms_limit(self):
66
+ self.assertEqual(_effective_char_limit(ChannelType.SMS), SMS_CHAR_LIMIT)
67
+
68
+ def test_whatsapp_limit(self):
69
+ self.assertEqual(_effective_char_limit(ChannelType.WHATSAPP), WHATSAPP_CHAR_LIMIT)
70
+
71
+
72
+ class TestOptOut(unittest.TestCase):
73
+ def test_appends_opt_out_to_sms(self):
74
+ result = _ensure_opt_out("Hi there!", ChannelType.SMS)
75
+ self.assertTrue(result.endswith(OPT_OUT_SUFFIX_SMS))
76
+
77
+ def test_appends_opt_out_to_whatsapp(self):
78
+ result = _ensure_opt_out("Hi there!", ChannelType.WHATSAPP)
79
+ self.assertTrue(result.endswith(OPT_OUT_SUFFIX_WHATSAPP))
80
+
81
+ def test_skips_when_reply_stop_present(self):
82
+ msg = "Reply STOP to unsubscribe."
83
+ result = _ensure_opt_out(msg, ChannelType.SMS)
84
+ self.assertEqual(result, msg)
85
+
86
+ def test_skips_when_text_stop_present(self):
87
+ msg = "Text STOP to cancel."
88
+ result = _ensure_opt_out(msg, ChannelType.SMS)
89
+ self.assertEqual(result, msg)
90
+
91
+ def test_skips_when_opt_out_present(self):
92
+ msg = "You may opt out at any time."
93
+ result = _ensure_opt_out(msg, ChannelType.SMS)
94
+ self.assertEqual(result, msg)
95
+
96
+ def test_skips_when_opt_hyphen_out_present(self):
97
+ msg = "Text opt-out to stop receiving messages."
98
+ result = _ensure_opt_out(msg, ChannelType.SMS)
99
+ self.assertEqual(result, msg)
100
+
101
+ def test_skips_when_unsubscribe_present(self):
102
+ msg = "Reply unsubscribe to stop."
103
+ result = _ensure_opt_out(msg, ChannelType.WHATSAPP)
104
+ self.assertEqual(result, msg)
105
+
106
+ def test_does_not_skip_for_stop_in_normal_prose(self):
107
+ msg = "Stop wasting hours on manual outreach."
108
+ result = _ensure_opt_out(msg, ChannelType.SMS)
109
+ self.assertTrue(result.endswith(OPT_OUT_SUFFIX_SMS))
110
+
111
+ def test_does_not_skip_for_stop_mid_sentence(self):
112
+ msg = "Let us help you stop losing deals."
113
+ result = _ensure_opt_out(msg, ChannelType.WHATSAPP)
114
+ self.assertTrue(result.endswith(OPT_OUT_SUFFIX_WHATSAPP))
115
+
116
+
117
+ class TestGenerateSMSWhatsAppCopy(unittest.TestCase):
118
+ def test_sms_generation_single_copy(self):
119
+ ctx = _build_context(ChannelType.SMS)
120
+ mock_response = SMSWhatsAppCopy(body="Hey Jane, quick question about Acme's outreach.")
121
+
122
+ with patch(
123
+ "dhisana.utils.generate_sms_whatsapp.get_structured_output_internal",
124
+ new_callable=AsyncMock,
125
+ return_value=(mock_response, "SUCCESS"),
126
+ ):
127
+ result = asyncio.get_event_loop().run_until_complete(
128
+ generate_sms_whatsapp_copy(
129
+ message_context=ctx,
130
+ message_instructions=ctx.message_instructions,
131
+ variation_text="Write a brief message.",
132
+ channel_type=ChannelType.SMS,
133
+ )
134
+ )
135
+ self.assertIn("body", result)
136
+ self.assertEqual(result["subject"], "")
137
+ self.assertIn("STOP", result["body"])
138
+
139
+ def test_whatsapp_generation_single_copy(self):
140
+ ctx = _build_context(ChannelType.WHATSAPP)
141
+ mock_response = SMSWhatsAppCopy(body="Hi Jane, Dhisana AI can help Acme automate outreach.")
142
+
143
+ with patch(
144
+ "dhisana.utils.generate_sms_whatsapp.get_structured_output_internal",
145
+ new_callable=AsyncMock,
146
+ return_value=(mock_response, "SUCCESS"),
147
+ ):
148
+ result = asyncio.get_event_loop().run_until_complete(
149
+ generate_sms_whatsapp_copy(
150
+ message_context=ctx,
151
+ message_instructions=ctx.message_instructions,
152
+ variation_text="Write a brief message.",
153
+ channel_type=ChannelType.WHATSAPP,
154
+ )
155
+ )
156
+ self.assertIn("body", result)
157
+ self.assertIn("STOP", result["body"])
158
+
159
+ def test_truncation_when_body_exceeds_limit(self):
160
+ ctx = _build_context(ChannelType.SMS)
161
+ long_body = "A" * 200
162
+ mock_response = SMSWhatsAppCopy(body=long_body)
163
+
164
+ with patch(
165
+ "dhisana.utils.generate_sms_whatsapp.get_structured_output_internal",
166
+ new_callable=AsyncMock,
167
+ return_value=(mock_response, "SUCCESS"),
168
+ ):
169
+ result = asyncio.get_event_loop().run_until_complete(
170
+ generate_sms_whatsapp_copy(
171
+ message_context=ctx,
172
+ message_instructions=ctx.message_instructions,
173
+ variation_text="Write a brief message.",
174
+ channel_type=ChannelType.SMS,
175
+ )
176
+ )
177
+ self.assertLessEqual(len(result["body"]), SMS_CHAR_LIMIT)
178
+
179
+ def test_raises_on_llm_failure(self):
180
+ ctx = _build_context(ChannelType.SMS)
181
+
182
+ with patch(
183
+ "dhisana.utils.generate_sms_whatsapp.get_structured_output_internal",
184
+ new_callable=AsyncMock,
185
+ return_value=(None, "ERROR"),
186
+ ):
187
+ with self.assertRaises(Exception) as exc_ctx:
188
+ asyncio.get_event_loop().run_until_complete(
189
+ generate_sms_whatsapp_copy(
190
+ message_context=ctx,
191
+ message_instructions=ctx.message_instructions,
192
+ variation_text="Write a brief message.",
193
+ channel_type=ChannelType.SMS,
194
+ )
195
+ )
196
+ self.assertIn("SMS", str(exc_ctx.exception))
197
+
198
+ def test_no_html_in_body(self):
199
+ ctx = _build_context(ChannelType.SMS)
200
+ mock_response = SMSWhatsAppCopy(body="Hi Jane")
201
+
202
+ with patch(
203
+ "dhisana.utils.generate_sms_whatsapp.get_structured_output_internal",
204
+ new_callable=AsyncMock,
205
+ return_value=(mock_response, "SUCCESS"),
206
+ ):
207
+ result = asyncio.get_event_loop().run_until_complete(
208
+ generate_sms_whatsapp_copy(
209
+ message_context=ctx,
210
+ message_instructions=ctx.message_instructions,
211
+ variation_text="Test",
212
+ channel_type=ChannelType.SMS,
213
+ )
214
+ )
215
+ self.assertIsNone(result.get("html_body"))
216
+
217
+
218
+ class TestGenerateSMSWhatsAppMessage(unittest.TestCase):
219
+ def test_generates_requested_number_of_variations(self):
220
+ ctx = _build_context(ChannelType.SMS)
221
+ mock_response = SMSWhatsAppCopy(body="Hey Jane, quick q about Acme.")
222
+
223
+ with patch(
224
+ "dhisana.utils.generate_sms_whatsapp.get_structured_output_internal",
225
+ new_callable=AsyncMock,
226
+ return_value=(mock_response, "SUCCESS"),
227
+ ):
228
+ results = asyncio.get_event_loop().run_until_complete(
229
+ generate_sms_whatsapp_message(ctx, number_of_variations=2)
230
+ )
231
+ self.assertEqual(len(results), 2)
232
+ for r in results:
233
+ self.assertIn("body", r)
234
+
235
+ def test_uses_framework_variations_when_no_user_instructions(self):
236
+ ctx = _build_context(ChannelType.WHATSAPP)
237
+ ctx.message_instructions.instructions_to_generate_message = None
238
+ mock_response = SMSWhatsAppCopy(body="Hi Jane, value-first msg.")
239
+
240
+ with patch(
241
+ "dhisana.utils.generate_sms_whatsapp.get_structured_output_internal",
242
+ new_callable=AsyncMock,
243
+ return_value=(mock_response, "SUCCESS"),
244
+ ) as mock_llm:
245
+ asyncio.get_event_loop().run_until_complete(
246
+ generate_sms_whatsapp_message(ctx, number_of_variations=3)
247
+ )
248
+ self.assertEqual(mock_llm.call_count, 3)
249
+
250
+
251
+ class TestChannelTypeEnum(unittest.TestCase):
252
+ def test_sms_in_channel_type(self):
253
+ self.assertEqual(ChannelType.SMS.value, "sms")
254
+
255
+ def test_whatsapp_in_channel_type(self):
256
+ self.assertEqual(ChannelType.WHATSAPP.value, "whatsapp")
257
+
258
+
259
+ if __name__ == "__main__":
260
+ unittest.main()
File without changes
File without changes