leadintel 0.3.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.
Files changed (54) hide show
  1. leadintel/__init__.py +6 -0
  2. leadintel/__main__.py +6 -0
  3. leadintel/config.py +165 -0
  4. leadintel/data/__init__.py +1 -0
  5. leadintel/data/demo.py +315 -0
  6. leadintel/exporters/__init__.py +6 -0
  7. leadintel/exporters/csv_exporter.py +113 -0
  8. leadintel/exporters/excel_exporter.py +123 -0
  9. leadintel/models/__init__.py +6 -0
  10. leadintel/models/business.py +203 -0
  11. leadintel/models/report.py +31 -0
  12. leadintel/providers/__init__.py +51 -0
  13. leadintel/providers/base.py +54 -0
  14. leadintel/providers/demo_provider.py +65 -0
  15. leadintel/providers/overpass_provider.py +1040 -0
  16. leadintel/providers/registry.py +151 -0
  17. leadintel/server.py +61 -0
  18. leadintel/services/__init__.py +48 -0
  19. leadintel/services/ai_business_insights.py +310 -0
  20. leadintel/services/business_comparison.py +220 -0
  21. leadintel/services/business_report.py +294 -0
  22. leadintel/services/competitor_research.py +293 -0
  23. leadintel/services/lead_scoring.py +385 -0
  24. leadintel/services/local_ai.py +186 -0
  25. leadintel/services/robots_checker.py +81 -0
  26. leadintel/services/seo_auditor.py +281 -0
  27. leadintel/services/social_finder.py +119 -0
  28. leadintel/services/url_validator.py +295 -0
  29. leadintel/services/website_analyzer.py +213 -0
  30. leadintel/services/website_opportunity.py +523 -0
  31. leadintel/storage/__init__.py +12 -0
  32. leadintel/storage/db.py +189 -0
  33. leadintel/storage/path_validator.py +118 -0
  34. leadintel/tools/__init__.py +38 -0
  35. leadintel/tools/analyze_website.py +57 -0
  36. leadintel/tools/check_seo.py +64 -0
  37. leadintel/tools/compare_businesses.py +65 -0
  38. leadintel/tools/export_leads.py +112 -0
  39. leadintel/tools/find_business_website.py +66 -0
  40. leadintel/tools/find_competitors.py +75 -0
  41. leadintel/tools/find_social_profiles.py +85 -0
  42. leadintel/tools/generate_business_insights.py +93 -0
  43. leadintel/tools/generate_business_report.py +92 -0
  44. leadintel/tools/generate_outreach.py +153 -0
  45. leadintel/tools/get_business_details.py +96 -0
  46. leadintel/tools/get_saved_leads.py +58 -0
  47. leadintel/tools/score_lead.py +88 -0
  48. leadintel/tools/search_businesses.py +305 -0
  49. leadintel/tools/website_finder.py +147 -0
  50. leadintel-0.3.0.dist-info/METADATA +351 -0
  51. leadintel-0.3.0.dist-info/RECORD +54 -0
  52. leadintel-0.3.0.dist-info/WHEEL +4 -0
  53. leadintel-0.3.0.dist-info/entry_points.txt +3 -0
  54. leadintel-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,151 @@
1
+ """
2
+ Provider registry — selects and chains providers at runtime.
3
+
4
+ Priority order (highest → lowest):
5
+ 1. OverpassProvider (free, OSM-backed, requires network)
6
+ 2. DemoProvider (always available, synthetic data)
7
+
8
+ Future providers (OpenStreetMapNominatim, custom) slot in without touching
9
+ any tool code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import inspect
15
+ import logging
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from ..models.business import Business
19
+ from .base import BusinessProvider
20
+
21
+ if TYPE_CHECKING:
22
+ pass
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class ProviderRegistry:
28
+ """Ordered list of providers with automatic fallback."""
29
+
30
+ def __init__(self, providers: list[BusinessProvider]) -> None:
31
+ if not providers:
32
+ raise ValueError("ProviderRegistry requires at least one provider.")
33
+ self._providers = providers
34
+ self.last_error: str | None = None
35
+ self.last_search_center: dict[str, float] | None = None
36
+ self.last_radius_meters: int | None = None
37
+
38
+ @property
39
+ def primary(self) -> BusinessProvider:
40
+ return self._providers[0]
41
+
42
+ @property
43
+ def all_providers(self) -> list[BusinessProvider]:
44
+ return list(self._providers)
45
+
46
+ def provider_names(self) -> list[str]:
47
+ return [p.name for p in self._providers]
48
+
49
+ async def search(
50
+ self,
51
+ query: str,
52
+ location: str | None = None,
53
+ category: str | None = None,
54
+ limit: int = 10,
55
+ **kwargs: Any,
56
+ ) -> tuple[list[Business], str]:
57
+ """
58
+ Try providers in order, return first non-empty result.
59
+
60
+ Returns:
61
+ (businesses, provider_name) — the name of the provider that
62
+ actually returned data.
63
+ """
64
+ self.last_error = None
65
+ self.last_search_center = None
66
+ self.last_radius_meters = None
67
+
68
+ for provider in self._providers:
69
+ # If a location or explicit coords were provided for live search, do not fall back to demo data
70
+ has_explicit_loc = bool(location or kwargs.get("latitude") is not None)
71
+ if has_explicit_loc and provider.name == "demo" and len(self._providers) > 1:
72
+ logger.debug(
73
+ "Skipping DemoProvider for live search with location '%s'.", location
74
+ )
75
+ continue
76
+
77
+ try:
78
+ sig = inspect.signature(provider.search)
79
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
80
+ call_kwargs = kwargs
81
+ else:
82
+ call_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
83
+
84
+ results = await provider.search(
85
+ query=query,
86
+ location=location,
87
+ category=category,
88
+ limit=limit,
89
+ **call_kwargs,
90
+ )
91
+
92
+ if hasattr(provider, "last_search_center"):
93
+ self.last_search_center = getattr(provider, "last_search_center", None)
94
+ if hasattr(provider, "last_radius_meters"):
95
+ self.last_radius_meters = getattr(provider, "last_radius_meters", None)
96
+
97
+ if results:
98
+ logger.debug(
99
+ "Provider '%s' returned %d result(s).",
100
+ provider.name,
101
+ len(results),
102
+ )
103
+ return results, provider.name
104
+
105
+ # If a live provider successfully executed search and found 0 results for a location,
106
+ # do not fall through to demo data — report 0 live results.
107
+ if has_explicit_loc and provider.name != "demo":
108
+ logger.debug(
109
+ "Live provider '%s' returned 0 results for location '%s'.",
110
+ provider.name,
111
+ location,
112
+ )
113
+ return [], provider.name
114
+
115
+ logger.debug("Provider '%s' returned 0 results, trying next.", provider.name)
116
+ except Exception as exc: # noqa: BLE001
117
+ self.last_error = str(exc)
118
+ logger.warning("Provider '%s' failed: %s", provider.name, exc)
119
+ # In live mode with a location, do not swallow the error into demo data
120
+ if has_explicit_loc and provider.name != "demo":
121
+ return [], provider.name
122
+
123
+ return [], self._providers[-1].name
124
+
125
+ async def get_details(
126
+ self, business_id: str
127
+ ) -> tuple[Business | None, str]:
128
+ """
129
+ Try providers in order for a single business lookup.
130
+
131
+ Returns:
132
+ (business_or_none, provider_name)
133
+ """
134
+ self.last_error = None
135
+ for provider in self._providers:
136
+ # Route by ID prefix when known
137
+ if business_id.startswith("osm_") and provider.name == "demo":
138
+ continue
139
+ if business_id.startswith("demo_") and provider.name != "demo":
140
+ continue
141
+
142
+ try:
143
+ result = await provider.get_details(business_id)
144
+ if result is not None:
145
+ return result, provider.name
146
+ except Exception as exc: # noqa: BLE001
147
+ self.last_error = str(exc)
148
+ logger.warning(
149
+ "Provider '%s' get_details failed: %s", provider.name, exc
150
+ )
151
+ return None, self._providers[-1].name
leadintel/server.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ LeadIntel MCP Server
3
+ ====================
4
+ Free, open-source MCP server for B2B lead generation and business intelligence.
5
+
6
+ Data sources:
7
+ - OpenStreetMap / Overpass API (free, ODbL licence)
8
+ - Demo data (always available, no network required)
9
+
10
+ Entry point — run with:
11
+ uv run python -m leadintel.server
12
+ or for interactive MCP Inspector:
13
+ uv run mcp dev src/leadintel/server.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+
20
+ from mcp.server.mcpserver import MCPServer
21
+
22
+ from .config import settings
23
+ from .storage.db import init_db
24
+ from .tools import register_all
25
+
26
+ # ── Logging ───────────────────────────────────────────────────────────────────
27
+ logging.basicConfig(
28
+ level=getattr(logging, settings.log_level.upper(), logging.INFO),
29
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
30
+ )
31
+ logger = logging.getLogger("leadintel")
32
+
33
+ # ── Initialize Storage ────────────────────────────────────────────────────────
34
+ try:
35
+ init_db()
36
+ except Exception as exc:
37
+ logger.warning("Failed to initialize database during startup: %s", exc)
38
+
39
+ # ── MCP Server ────────────────────────────────────────────────────────────────
40
+ mcp = MCPServer(
41
+ name="leadintel",
42
+ version="0.3.0",
43
+ instructions=(
44
+ "LeadIntel is a free, open-source MCP server for B2B lead generation "
45
+ "and business intelligence. It uses OpenStreetMap (free, no API key "
46
+ "required) for live business discovery. Use search_businesses to find "
47
+ "businesses in any city, export_leads to save results, and the "
48
+ "upcoming tools (analyze_website, score_lead, etc.) for enrichment. "
49
+ "When source is 'openstreetmap', you MUST display the attribution: "
50
+ "'Data © OpenStreetMap contributors, ODbL 1.0'."
51
+ ),
52
+ )
53
+
54
+ # Register all tools
55
+ register_all(mcp)
56
+
57
+ logger.info("LeadIntel MCP server initialised. Config: %s", settings.safe_dict())
58
+
59
+ # ── Entrypoint ────────────────────────────────────────────────────────────────
60
+ if __name__ == "__main__":
61
+ mcp.run()
@@ -0,0 +1,48 @@
1
+ """LeadIntel modular service package."""
2
+
3
+ from .ai_business_insights import generate_ai_business_insights, generate_ai_outreach
4
+ from .business_comparison import compare_target_against_competitors
5
+ from .business_report import generate_comprehensive_business_report
6
+ from .competitor_research import calculate_competitor_relevance, find_industry_competitors
7
+ from .lead_scoring import score_business_lead
8
+ from .local_ai import (
9
+ LocalAIClient,
10
+ OllamaError,
11
+ OllamaModelNotFoundError,
12
+ OllamaUnavailableError,
13
+ local_ai_client,
14
+ )
15
+ from .robots_checker import RobotsChecker
16
+ from .seo_auditor import audit_html_seo, run_full_seo_audit
17
+ from .social_finder import extract_social_profiles
18
+ from .url_validator import (
19
+ SecurityValidationError,
20
+ create_safe_client,
21
+ validate_and_normalize_url,
22
+ validate_redirect_hook,
23
+ )
24
+ from .website_analyzer import analyze_target_website
25
+
26
+ __all__ = [
27
+ "SecurityValidationError",
28
+ "validate_and_normalize_url",
29
+ "create_safe_client",
30
+ "validate_redirect_hook",
31
+ "RobotsChecker",
32
+ "extract_social_profiles",
33
+ "audit_html_seo",
34
+ "run_full_seo_audit",
35
+ "analyze_target_website",
36
+ "calculate_competitor_relevance",
37
+ "find_industry_competitors",
38
+ "compare_target_against_competitors",
39
+ "score_business_lead",
40
+ "LocalAIClient",
41
+ "OllamaError",
42
+ "OllamaUnavailableError",
43
+ "OllamaModelNotFoundError",
44
+ "local_ai_client",
45
+ "generate_ai_business_insights",
46
+ "generate_ai_outreach",
47
+ "generate_comprehensive_business_report",
48
+ ]
@@ -0,0 +1,310 @@
1
+ """
2
+ AI Business Insights & Outreach Generation Service.
3
+
4
+ Bridges deterministic LeadIntel intelligence (website analysis, SEO audits, competitor benchmarks, lead scores)
5
+ with local Ollama inference.
6
+
7
+ Strict Ethical Rules:
8
+ - NEVER fabricates business revenue, marketing budgets, purchasing intent, or competitor weaknesses.
9
+ - Labels all AI-generated synthesis clearly as `is_ai_generated: true` while preserving verified factual sources.
10
+ - Grounded strictly in provided evidence: if data is absent, notes it explicitly rather than hallucinating.
11
+ - Generates high-impact B2B outreach across Email, LinkedIn, and WhatsApp Business.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from typing import Any, Optional
19
+
20
+ import httpx
21
+
22
+ from .local_ai import LocalAIClient, local_ai_client
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ SYSTEM_PROMPT_INSIGHTS = """You are a senior business intelligence and digital strategy analyst.
27
+ Your job is to provide objective, evidence-based insights for a business based EXCLUSIVELY on provided verified technical data.
28
+
29
+ STRICT CONSTRAINTS:
30
+ 1. NEVER invent or assume company revenue, marketing budget, or customer purchasing intent.
31
+ 2. Ground all recommendations strictly in observable digital defects and gaps (e.g. missing SSL, slow response, missing H1, poor mobile viewport, lack of social links).
32
+ 3. Distinguish verified facts from AI strategic interpretations.
33
+ 4. Output valid JSON when requested.
34
+ """
35
+
36
+ SYSTEM_PROMPT_OUTREACH = """You are an expert B2B digital agency consultant writing personalized, respectful, and highly specific outreach messages to a business decision maker.
37
+
38
+ STRICT CONSTRAINTS & GUARDRAILS:
39
+ 1. Do not fabricate business data or website problems. Base the outreach strictly on the provided website_opportunity_explanation.
40
+ 2. If the site is missing (website_not_found_after_research), pitch a new build.
41
+ 3. If a CMS is detected or a contact route is missing, mention it as a specific reason for outreach.
42
+ 4. Do not use generic buzzwords or clickbait claims.
43
+ 5. Propose a collaborative, value-first conversation without assuming their budget or readiness to buy.
44
+ 6. Generate three distinct channels: Email Pitch, LinkedIn Message, and WhatsApp Business message.
45
+ 7. Output valid JSON.
46
+ """
47
+
48
+
49
+ def _build_context_summary(
50
+ business_name: str,
51
+ business_category: Optional[str] = None,
52
+ website_status: Optional[str] = None,
53
+ website_opportunity_score: Optional[float] = None,
54
+ website_opportunity_explanation: Optional[str] = None,
55
+ detected_cms: Optional[str] = None,
56
+ has_contact_route: Optional[bool] = None,
57
+ website_analysis: Optional[dict[str, Any]] = None,
58
+ seo_audit: Optional[dict[str, Any]] = None,
59
+ social_profiles: Optional[dict[str, Any]] = None,
60
+ competitor_comparison: Optional[dict[str, Any]] = None,
61
+ lead_score_data: Optional[dict[str, Any]] = None,
62
+ contact_info: Optional[dict[str, Any]] = None,
63
+ ) -> dict[str, Any]:
64
+ """Compile structured verified facts to ground AI synthesis."""
65
+ verified_facts: dict[str, Any] = {
66
+ "business_name": business_name,
67
+ "category": business_category or "Unspecified",
68
+ }
69
+
70
+ website_opp: dict[str, Any] = {}
71
+ if website_status:
72
+ website_opp["website_status"] = website_status
73
+ if website_opportunity_score is not None:
74
+ website_opp["website_opportunity_score"] = website_opportunity_score
75
+ if website_opportunity_explanation:
76
+ website_opp["website_opportunity_explanation"] = website_opportunity_explanation
77
+ if detected_cms:
78
+ website_opp["detected_cms"] = detected_cms
79
+ if has_contact_route is not None:
80
+ website_opp["has_contact_route"] = has_contact_route
81
+
82
+ if website_opp:
83
+ verified_facts["website_opportunity_and_tech"] = website_opp
84
+
85
+ if contact_info:
86
+ verified_facts["contact"] = {
87
+ "city": contact_info.get("city"),
88
+ "has_phone": bool(contact_info.get("phone")),
89
+ "has_email": bool(contact_info.get("email")),
90
+ }
91
+
92
+ if website_analysis:
93
+ verified_facts["website"] = {
94
+ "url": website_analysis.get("url"),
95
+ "available": website_analysis.get("available"),
96
+ "is_https": website_analysis.get("protocol", {}).get("is_https"),
97
+ "response_time_seconds": website_analysis.get("response_time_seconds"),
98
+ "mobile_viewport_configured": website_analysis.get("metadata", {}).get("has_mobile_viewport"),
99
+ "has_contact_form": website_analysis.get("contact_detection", {}).get("has_contact_form"),
100
+ }
101
+
102
+ if seo_audit:
103
+ verified_facts["seo"] = {
104
+ "score": seo_audit.get("seo_score"),
105
+ "issues_sample": [iss.get("message") for iss in seo_audit.get("issues", [])][:4],
106
+ }
107
+
108
+ if social_profiles:
109
+ profiles_dict = social_profiles.get("profiles", social_profiles)
110
+ active = [plat for plat, links in profiles_dict.items() if isinstance(links, list) and len(links) > 0]
111
+ verified_facts["social_media"] = {
112
+ "active_channels": active,
113
+ "channel_count": len(active),
114
+ }
115
+
116
+ if competitor_comparison:
117
+ verified_facts["competitor_context"] = {
118
+ "competitors_compared": competitor_comparison.get("competitors_compared_count", 0),
119
+ "strengths_sample": competitor_comparison.get("strengths", [])[:2],
120
+ "weaknesses_sample": competitor_comparison.get("weaknesses", [])[:2],
121
+ }
122
+
123
+ if lead_score_data:
124
+ verified_facts["lead_score"] = {
125
+ "overall_score": lead_score_data.get("overall_lead_score"),
126
+ "priority": lead_score_data.get("priority_classification"),
127
+ "service_opportunities": lead_score_data.get("service_opportunities"),
128
+ "recommended_package": lead_score_data.get("recommended_service_package"),
129
+ "verified_issues": lead_score_data.get("verified_issues", [])[:5],
130
+ }
131
+
132
+ return verified_facts
133
+
134
+
135
+ async def generate_ai_business_insights(
136
+ business_name: str,
137
+ business_category: Optional[str] = None,
138
+ website_analysis: Optional[dict[str, Any]] = None,
139
+ seo_audit: Optional[dict[str, Any]] = None,
140
+ social_profiles: Optional[dict[str, Any]] = None,
141
+ competitor_comparison: Optional[dict[str, Any]] = None,
142
+ lead_score_data: Optional[dict[str, Any]] = None,
143
+ contact_info: Optional[dict[str, Any]] = None,
144
+ ai_client: Optional[LocalAIClient] = None,
145
+ http_client: Optional[httpx.AsyncClient] = None,
146
+ ) -> dict[str, Any]:
147
+ """
148
+ Generate evidence-based strategic business insights using local Ollama.
149
+ """
150
+ if not business_name or not business_name.strip():
151
+ raise ValueError("business_name must be a non-empty string.")
152
+
153
+ client = ai_client or local_ai_client
154
+ verified_data = _build_context_summary(
155
+ business_name=business_name,
156
+ business_category=business_category,
157
+ website_analysis=website_analysis,
158
+ seo_audit=seo_audit,
159
+ social_profiles=social_profiles,
160
+ competitor_comparison=competitor_comparison,
161
+ lead_score_data=lead_score_data,
162
+ contact_info=contact_info,
163
+ )
164
+
165
+ prompt = f"""Analyze the verified business data below and produce a structured strategic insight report in JSON format.
166
+
167
+ VERIFIED DATA:
168
+ {json.dumps(verified_data, indent=2)}
169
+
170
+ Required JSON schema:
171
+ {{
172
+ "executive_summary": "string summarizing digital maturity and key findings",
173
+ "opportunity_explanation": "string explaining why specific digital opportunities exist based on the evidence",
174
+ "lead_score_interpretation": "string contextualizing the deterministic lead score without altering it",
175
+ "competitor_insights": "string comparing their posture against market peers",
176
+ "service_recommendations": [
177
+ {{
178
+ "service": "Website Development | SEO | Social Media Management | Digital Marketing",
179
+ "rationale": "specific evidence-backed reason",
180
+ "priority": "High | Medium | Low"
181
+ }}
182
+ ]
183
+ }}
184
+ """
185
+
186
+ raw_response = await client.generate(
187
+ prompt=prompt,
188
+ system_prompt=SYSTEM_PROMPT_INSIGHTS,
189
+ format_json=True,
190
+ client=http_client,
191
+ )
192
+
193
+ try:
194
+ parsed_insights = json.loads(raw_response)
195
+ except json.JSONDecodeError:
196
+ parsed_insights = {
197
+ "executive_summary": raw_response,
198
+ "opportunity_explanation": "Extracted from local AI response.",
199
+ "lead_score_interpretation": "See deterministic lead score report.",
200
+ "competitor_insights": "Refer to competitor comparison matrix.",
201
+ "service_recommendations": [],
202
+ }
203
+
204
+ return {
205
+ "business_name": business_name,
206
+ "is_ai_generated": True,
207
+ "ai_model_used": client.model,
208
+ "verified_facts_grounding": verified_data,
209
+ "ai_insights": parsed_insights,
210
+ "disclaimer": (
211
+ "Observations under 'ai_insights' were synthesized by a local AI model grounded strictly in verified data. "
212
+ "No assumptions regarding customer budget or revenue were made."
213
+ ),
214
+ }
215
+
216
+
217
+ async def generate_ai_outreach(
218
+ business_name: str,
219
+ business_category: Optional[str] = None,
220
+ website_status: Optional[str] = None,
221
+ website_opportunity_score: Optional[float] = None,
222
+ website_opportunity_explanation: Optional[str] = None,
223
+ detected_cms: Optional[str] = None,
224
+ has_contact_route: Optional[bool] = None,
225
+ website_analysis: Optional[dict[str, Any]] = None,
226
+ seo_audit: Optional[dict[str, Any]] = None,
227
+ social_profiles: Optional[dict[str, Any]] = None,
228
+ competitor_comparison: Optional[dict[str, Any]] = None,
229
+ lead_score_data: Optional[dict[str, Any]] = None,
230
+ contact_info: Optional[dict[str, Any]] = None,
231
+ agency_name: str = "Our Agency",
232
+ sender_name: str = "Lead Specialist",
233
+ ai_client: Optional[LocalAIClient] = None,
234
+ http_client: Optional[httpx.AsyncClient] = None,
235
+ ) -> dict[str, Any]:
236
+ """
237
+ Generate tailored B2B outreach messages across Email, LinkedIn, and WhatsApp Business.
238
+ """
239
+ if not business_name or not business_name.strip():
240
+ raise ValueError("business_name must be a non-empty string.")
241
+
242
+ client = ai_client or local_ai_client
243
+ verified_data = _build_context_summary(
244
+ business_name=business_name,
245
+ business_category=business_category,
246
+ website_status=website_status,
247
+ website_opportunity_score=website_opportunity_score,
248
+ website_opportunity_explanation=website_opportunity_explanation,
249
+ detected_cms=detected_cms,
250
+ has_contact_route=has_contact_route,
251
+ website_analysis=website_analysis,
252
+ seo_audit=seo_audit,
253
+ social_profiles=social_profiles,
254
+ competitor_comparison=competitor_comparison,
255
+ lead_score_data=lead_score_data,
256
+ contact_info=contact_info,
257
+ )
258
+
259
+ prompt = f"""Generate personalized, professional B2B outreach messages for {business_name} in JSON format.
260
+ Sender: {sender_name} representing {agency_name}.
261
+
262
+ STRICT GUARDRAILS:
263
+ - Do not fabricate business data or website problems. Base the outreach strictly on the provided website_opportunity_explanation.
264
+ - If the site is missing (website_not_found_after_research), pitch a new build.
265
+ - If a CMS is detected or a contact route is missing, mention it as a specific reason for outreach.
266
+
267
+ VERIFIED CONTEXT:
268
+ {json.dumps(verified_data, indent=2)}
269
+
270
+ Required JSON schema:
271
+ {{
272
+ "email_pitch": {{
273
+ "subject": "Clear, curiosity-inducing subject line",
274
+ "body": "Professional 3-paragraph email referencing 1-2 verified technical findings and a low-friction call-to-action."
275
+ }},
276
+ "linkedin_message": {{
277
+ "message": "Concise connection message (under 300 characters) referencing their business and specific digital insight."
278
+ }},
279
+ "whatsapp_message": {{
280
+ "message": "Polite, direct WhatsApp Business message (under 120 words) with clear value proposition and polite sign-off."
281
+ }}
282
+ }}
283
+ """
284
+
285
+ raw_response = await client.generate(
286
+ prompt=prompt,
287
+ system_prompt=SYSTEM_PROMPT_OUTREACH,
288
+ format_json=True,
289
+ client=http_client,
290
+ )
291
+
292
+ try:
293
+ parsed_outreach = json.loads(raw_response)
294
+ except json.JSONDecodeError:
295
+ parsed_outreach = {
296
+ "email_pitch": {"subject": f"Quick thought regarding {business_name}", "body": raw_response},
297
+ "linkedin_message": {"message": raw_response[:280]},
298
+ "whatsapp_message": {"message": raw_response[:300]},
299
+ }
300
+
301
+ return {
302
+ "business_name": business_name,
303
+ "is_ai_generated": True,
304
+ "ai_model_used": client.model,
305
+ "outreach_campaign": parsed_outreach,
306
+ "disclaimer": (
307
+ "These outreach drafts were synthesized by local AI using verified evidence. "
308
+ "Review and adapt messages before sending to prospects."
309
+ ),
310
+ }