devrel-origin 0.2.14__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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pax -- Sales Enablement Agent
|
|
3
|
+
|
|
4
|
+
On-demand sales asset generation: outreach emails, battle cards,
|
|
5
|
+
nurture sequences, objection handling docs, and one-pagers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
15
|
+
|
|
16
|
+
from devrel_origin.core.base import get_kb_search, load_agent_prompt
|
|
17
|
+
from devrel_origin.core.llm import LLMClient
|
|
18
|
+
from devrel_origin.quality import generate_with_pipeline
|
|
19
|
+
from devrel_origin.tools.api_client import PostHogClient
|
|
20
|
+
from devrel_origin.tools.instantly_client import InstantlyClient, InstantlyLead
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from devrel_origin.tools.apollo_client import ApolloClient, ApolloContact
|
|
24
|
+
from devrel_origin.tools.search_tools import SearchTools
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class OutreachEmail:
|
|
31
|
+
"""A personalized outreach email."""
|
|
32
|
+
|
|
33
|
+
subject: str
|
|
34
|
+
body: str
|
|
35
|
+
personalization_hooks: list[str]
|
|
36
|
+
pain_points_addressed: list[str]
|
|
37
|
+
cta: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class BattleCard:
|
|
42
|
+
"""One-page competitive comparison document."""
|
|
43
|
+
|
|
44
|
+
competitor: str
|
|
45
|
+
comparison_table: dict[str, dict[str, str]]
|
|
46
|
+
objection_responses: list[dict[str, str]]
|
|
47
|
+
win_themes: list[str]
|
|
48
|
+
proof_points: list[str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class NurtureSequence:
|
|
53
|
+
"""Multi-step email drip campaign."""
|
|
54
|
+
|
|
55
|
+
segment: str
|
|
56
|
+
goal: str
|
|
57
|
+
cadence_days: list[int]
|
|
58
|
+
emails: list[OutreachEmail]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SalesAsset:
|
|
63
|
+
"""Generic sales document."""
|
|
64
|
+
|
|
65
|
+
title: str
|
|
66
|
+
asset_type: str
|
|
67
|
+
body: str
|
|
68
|
+
target_persona: str
|
|
69
|
+
target_vertical: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class PersonalizedOutreach:
|
|
74
|
+
"""A fully personalized outreach email for a specific lead."""
|
|
75
|
+
|
|
76
|
+
contact_id: str
|
|
77
|
+
first_name: str
|
|
78
|
+
last_name: str
|
|
79
|
+
email: str
|
|
80
|
+
title: str
|
|
81
|
+
company_name: str
|
|
82
|
+
research_hook: str
|
|
83
|
+
research_source: str
|
|
84
|
+
subject: str
|
|
85
|
+
body: str
|
|
86
|
+
pain_points_addressed: list[str]
|
|
87
|
+
sales_psychology: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Pax:
|
|
91
|
+
"""
|
|
92
|
+
Sales Enablement agent for on-demand asset generation.
|
|
93
|
+
|
|
94
|
+
Capabilities:
|
|
95
|
+
- Outreach emails personalized with community pain points
|
|
96
|
+
- Battle cards grounded in Rex's competitive intelligence
|
|
97
|
+
- Nurture sequences for different audience segments
|
|
98
|
+
- One-pagers and objection handling docs
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
_DEFAULT_SYSTEM_PROMPT = """You are Pax, a sales enablement specialist for {product_name}. \
|
|
102
|
+
Your role is to produce sales assets that help close deals: outreach emails, battle cards, \
|
|
103
|
+
nurture sequences, one-pagers, and objection handling docs.
|
|
104
|
+
|
|
105
|
+
Core Guidelines:
|
|
106
|
+
1. EVIDENCE-BASED -- Ground every claim in knowledge base facts, competitive \
|
|
107
|
+
data, or real community pain points. No empty marketing speak.
|
|
108
|
+
2. DEVELOPER-AWARE -- The buyer is often a developer or technical leader. \
|
|
109
|
+
Respect their intelligence. Lead with value, not hype.
|
|
110
|
+
3. PERSONALIZED -- Use upstream pain points and competitive gaps to make \
|
|
111
|
+
outreach specific and relevant to the recipient's situation.
|
|
112
|
+
4. ACTIONABLE -- Every asset should have a clear CTA and next step.
|
|
113
|
+
5. HONEST -- Never misrepresent capabilities. Acknowledge limitations when \
|
|
114
|
+
they exist -- credibility matters more than closing one deal.
|
|
115
|
+
|
|
116
|
+
Copywriting Psychology:
|
|
117
|
+
6. SELL THE MOTIVE, NOT THE NEED -- People don't buy a tool (Need); they buy \
|
|
118
|
+
the ability to stop worrying, to look smart in front of their boss, to sleep \
|
|
119
|
+
at night (Motive). Lead with the emotional payoff, then back it with evidence.
|
|
120
|
+
7. SELL THE NEXT STEP -- Every asset sells exactly one next step. A cold email \
|
|
121
|
+
sells a 10-minute demo call, not a contract. A battle card sells internal buy-in, \
|
|
122
|
+
not a purchase order. Match the CTA to the funnel stage.
|
|
123
|
+
8. FRICTIONLESS READING -- Short paragraphs (max 5 lines). Hard data over \
|
|
124
|
+
adjectives ("$500K team cost to $500/month" not "revolutionary savings"). \
|
|
125
|
+
Never end with an open question -- end with a direct CTA.
|
|
126
|
+
9. STORYTELLING -- Use the Fairytale Framework when appropriate: "Once upon a \
|
|
127
|
+
time..." (old way/pain) -> "And then one day..." (discovery) -> "And now..." \
|
|
128
|
+
(dream outcome). Stories bypass critical thinking and build trust.
|
|
129
|
+
|
|
130
|
+
Hormozi Offer Strategy:
|
|
131
|
+
10. VALUE EQUATION -- Frame every offer using: Value = (Dream Outcome x \
|
|
132
|
+
Perceived Likelihood) / (Time Delay x Effort). Maximize the top, drive the \
|
|
133
|
+
bottom to zero. Show high outcome, high likelihood, instant results, zero effort.
|
|
134
|
+
11. RISK REVERSAL -- Include guarantees when possible ("Run it on your repo \
|
|
135
|
+
for free first", "Don't pay until we generate 10 qualified leads"). Risk \
|
|
136
|
+
reversal is the single biggest conversion driver.
|
|
137
|
+
12. PREMIUM POSITIONING -- Never compete on price. Position as "replace a \
|
|
138
|
+
$500K-$1M team" not "cheap alternative". High prices attract better clients.
|
|
139
|
+
13. GIVE INFO, SELL IMPLEMENTATION -- In lead magnets and content-adjacent \
|
|
140
|
+
assets, give away the strategy openly. Sell the done-for-you execution."""
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def SYSTEM_PROMPT(self) -> str:
|
|
144
|
+
return self._system_prompt
|
|
145
|
+
|
|
146
|
+
# Order matters: more specific types must come before generic ones.
|
|
147
|
+
# "triage_replies" must precede "outreach" because tasks containing
|
|
148
|
+
# "email replies" would otherwise match "email" → outreach.
|
|
149
|
+
ASSET_KEYWORDS: dict[str, list[str]] = {
|
|
150
|
+
"triage_replies": ["triage", "replies", "follow-up"],
|
|
151
|
+
"lead_upload": ["upload leads", "import leads", "add leads"],
|
|
152
|
+
"prospect_personalize": [
|
|
153
|
+
"leads and personalize",
|
|
154
|
+
"prospect and personalize",
|
|
155
|
+
"personalized outreach",
|
|
156
|
+
],
|
|
157
|
+
"prospect_leads": ["find leads", "apollo search", "icp", "prospect leads"],
|
|
158
|
+
"enrich_upload": ["enrich", "enrich and upload", "apollo enrich"],
|
|
159
|
+
"instantly_campaign": ["instantly", "cold email", "outreach campaign"],
|
|
160
|
+
"nurture": ["nurture", "drip", "sequence"],
|
|
161
|
+
"battle_card": ["battle card", "vs", "comparison"],
|
|
162
|
+
"outreach": ["outreach", "email", "prospect"],
|
|
163
|
+
"one_pager": ["one-pager", "one pager", "summary"],
|
|
164
|
+
"objection": ["objection", "faq", "pushback"],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
FOLLOWUP_CATEGORIES = {"interested", "objection"}
|
|
168
|
+
|
|
169
|
+
TRIAGE_PROMPT = """Classify this email reply into one of: \
|
|
170
|
+
interested, objection, not_now, unsubscribe, auto_reply.
|
|
171
|
+
|
|
172
|
+
Reply text:
|
|
173
|
+
---
|
|
174
|
+
{reply_body}
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
Return a JSON object: {{"category": "..."}}"""
|
|
178
|
+
|
|
179
|
+
FOLLOWUP_PROMPT = """Draft a follow-up email for this reply.
|
|
180
|
+
|
|
181
|
+
Category: {category}
|
|
182
|
+
Original reply: {reply_body}
|
|
183
|
+
Lead: {lead_email}
|
|
184
|
+
|
|
185
|
+
## Knowledge Base Context
|
|
186
|
+
{kb_context}
|
|
187
|
+
|
|
188
|
+
Write a personalized, non-salesy follow-up. Be helpful and specific.
|
|
189
|
+
Return JSON: {{"subject": "...", "body": "..."}}"""
|
|
190
|
+
|
|
191
|
+
# Load optimized prompts via the shared util (delegates to optimize/pax/<file>).
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def _load_prompt(cls, filename: str, default: str) -> str:
|
|
195
|
+
"""Load prompt from optimize/pax/<filename> via the shared util."""
|
|
196
|
+
return load_agent_prompt("pax", filename, default)
|
|
197
|
+
|
|
198
|
+
_DEFAULT_EMAIL_PROMPT = """Write a personalized cold email for this prospect.
|
|
199
|
+
|
|
200
|
+
## Prospect
|
|
201
|
+
- Name: {first_name} {last_name}
|
|
202
|
+
- Title: {title}
|
|
203
|
+
- Company: {company_name}
|
|
204
|
+
- Research hook: {research_hook}
|
|
205
|
+
|
|
206
|
+
## Knowledge Base
|
|
207
|
+
{kb_context}
|
|
208
|
+
|
|
209
|
+
## Competitive Context
|
|
210
|
+
{competitive_context}
|
|
211
|
+
|
|
212
|
+
## Instructions
|
|
213
|
+
- Open with the research hook (reference something specific about them or their company)
|
|
214
|
+
- Connect their likely pain point to {product_name}'s solution
|
|
215
|
+
- Apply the Value Equation: show dream outcome, prove likelihood, emphasize speed, minimize effort
|
|
216
|
+
- Include risk reversal (free trial, no commitment, etc.)
|
|
217
|
+
- One clear CTA: book a call via {{sales_cta_url}}
|
|
218
|
+
- Keep it under 150 words
|
|
219
|
+
- No buzzwords, no "I hope this email finds you well"
|
|
220
|
+
- Sound like a technical peer, not a salesperson
|
|
221
|
+
- Sign the email as the product owner, never as "Pax" or any agent name
|
|
222
|
+
|
|
223
|
+
Return JSON:
|
|
224
|
+
{{"subject": "...", "body": "...", "pain_points_addressed": ["..."], "sales_psychology": "which framework you applied"}}"""
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def PERSONALIZED_EMAIL_PROMPT(self) -> str:
|
|
228
|
+
return self._load_prompt("email_prompt.txt", self._DEFAULT_EMAIL_PROMPT)
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
api_client: PostHogClient,
|
|
233
|
+
knowledge_base_path: Path,
|
|
234
|
+
llm_client: Optional[LLMClient] = None,
|
|
235
|
+
instantly_client: Optional[InstantlyClient] = None,
|
|
236
|
+
apollo_client: Optional["ApolloClient"] = None,
|
|
237
|
+
search_tools: Optional["SearchTools"] = None,
|
|
238
|
+
product_name: str = "the target product",
|
|
239
|
+
):
|
|
240
|
+
self.api_client = api_client
|
|
241
|
+
self.knowledge_base_path = knowledge_base_path
|
|
242
|
+
self.llm_client = llm_client
|
|
243
|
+
self.instantly_client = instantly_client
|
|
244
|
+
self.apollo_client = apollo_client
|
|
245
|
+
self.search_tools = search_tools
|
|
246
|
+
self.product_name = product_name
|
|
247
|
+
self.sales_cta_url = os.getenv("SALES_CTA_URL", "https://example.com/book")
|
|
248
|
+
self.BULK_BATCH_SIZE = 1000
|
|
249
|
+
self._kb = get_kb_search(
|
|
250
|
+
knowledge_base_path,
|
|
251
|
+
extra_stop_words=frozenset(
|
|
252
|
+
{
|
|
253
|
+
"generate",
|
|
254
|
+
"create",
|
|
255
|
+
"write",
|
|
256
|
+
"outreach",
|
|
257
|
+
"emails",
|
|
258
|
+
"battle",
|
|
259
|
+
"card",
|
|
260
|
+
"nurture",
|
|
261
|
+
"sequence",
|
|
262
|
+
"one-pager",
|
|
263
|
+
}
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
self._system_prompt = self._load_prompt("system_prompt.txt", self._DEFAULT_SYSTEM_PROMPT)
|
|
267
|
+
|
|
268
|
+
def _collect_leads(
|
|
269
|
+
self,
|
|
270
|
+
leads: list[dict] | None = None,
|
|
271
|
+
csv_path: Path | None = None,
|
|
272
|
+
context: dict[str, Any] | None = None,
|
|
273
|
+
) -> list[InstantlyLead]:
|
|
274
|
+
"""Parse leads from dicts, CSV, and/or upstream context."""
|
|
275
|
+
parsed: list[InstantlyLead] = []
|
|
276
|
+
|
|
277
|
+
if leads:
|
|
278
|
+
for lead in leads:
|
|
279
|
+
parsed.append(
|
|
280
|
+
InstantlyLead(
|
|
281
|
+
email=lead.get("email", ""),
|
|
282
|
+
first_name=lead.get("first_name", ""),
|
|
283
|
+
last_name=lead.get("last_name", ""),
|
|
284
|
+
company_name=lead.get("company_name", ""),
|
|
285
|
+
title=lead.get("title", ""),
|
|
286
|
+
custom_variables=lead.get("custom_variables", {}),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if csv_path and csv_path.exists():
|
|
291
|
+
with open(csv_path) as f:
|
|
292
|
+
for row in csv.DictReader(f):
|
|
293
|
+
parsed.append(
|
|
294
|
+
InstantlyLead(
|
|
295
|
+
email=row.get("email", ""),
|
|
296
|
+
first_name=row.get("first_name", ""),
|
|
297
|
+
last_name=row.get("last_name", ""),
|
|
298
|
+
company_name=row.get("company_name", ""),
|
|
299
|
+
title=row.get("title", ""),
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if context and "sage_triage" in context:
|
|
304
|
+
for issue in context["sage_triage"].get("issues", []):
|
|
305
|
+
email = issue.get("author_email")
|
|
306
|
+
if email:
|
|
307
|
+
parsed.append(
|
|
308
|
+
InstantlyLead(
|
|
309
|
+
email=email,
|
|
310
|
+
first_name=issue.get("author", ""),
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return [lead for lead in parsed if lead.email]
|
|
315
|
+
|
|
316
|
+
async def upload_leads(
|
|
317
|
+
self,
|
|
318
|
+
campaign_id: str,
|
|
319
|
+
leads: list[dict] | None = None,
|
|
320
|
+
csv_path: Path | None = None,
|
|
321
|
+
context: dict[str, Any] | None = None,
|
|
322
|
+
) -> dict[str, Any]:
|
|
323
|
+
"""Upload leads to Instantly from dicts, CSV, or upstream context."""
|
|
324
|
+
parsed_leads = self._collect_leads(leads, csv_path, context)
|
|
325
|
+
|
|
326
|
+
if not parsed_leads or not self.instantly_client:
|
|
327
|
+
return {"total_uploaded": 0, "batches": 0, "errors": []}
|
|
328
|
+
|
|
329
|
+
total_uploaded = 0
|
|
330
|
+
errors: list[str] = []
|
|
331
|
+
batches = 0
|
|
332
|
+
for i in range(0, len(parsed_leads), self.BULK_BATCH_SIZE):
|
|
333
|
+
batch = parsed_leads[i : i + self.BULK_BATCH_SIZE]
|
|
334
|
+
try:
|
|
335
|
+
result = await self.instantly_client.add_leads_bulk(campaign_id, batch)
|
|
336
|
+
total_uploaded += result.get("added", len(batch))
|
|
337
|
+
batches += 1
|
|
338
|
+
except Exception as e:
|
|
339
|
+
errors.append(str(e))
|
|
340
|
+
logger.warning(f"Bulk upload batch {batches} failed: {e}")
|
|
341
|
+
|
|
342
|
+
return {"total_uploaded": total_uploaded, "batches": batches, "errors": errors}
|
|
343
|
+
|
|
344
|
+
async def prospect_leads(
|
|
345
|
+
self,
|
|
346
|
+
criteria: dict,
|
|
347
|
+
) -> list["ApolloContact"]:
|
|
348
|
+
"""Search Apollo for contacts matching ICP criteria.
|
|
349
|
+
|
|
350
|
+
criteria keys: titles, domains, industries, min_headcount, max_headcount
|
|
351
|
+
Returns list of ApolloContact. Returns [] if no apollo_client.
|
|
352
|
+
"""
|
|
353
|
+
if not self.apollo_client:
|
|
354
|
+
return []
|
|
355
|
+
# Build search params, converting headcount to Apollo's format
|
|
356
|
+
# Note: skip 'industries' — Apollo requires specific tag IDs, not text
|
|
357
|
+
search_params: dict[str, Any] = {}
|
|
358
|
+
for key in ("titles", "domains"):
|
|
359
|
+
if key in criteria:
|
|
360
|
+
search_params[key] = criteria[key]
|
|
361
|
+
min_hc = criteria.get("min_headcount")
|
|
362
|
+
max_hc = criteria.get("max_headcount")
|
|
363
|
+
if min_hc is not None or max_hc is not None:
|
|
364
|
+
search_params["organization_num_employees_ranges"] = [
|
|
365
|
+
f"{min_hc or 1},{max_hc or 100000}"
|
|
366
|
+
]
|
|
367
|
+
result = await self.apollo_client.search_people(**search_params)
|
|
368
|
+
return result.contacts
|
|
369
|
+
|
|
370
|
+
async def enrich_and_upload(
|
|
371
|
+
self,
|
|
372
|
+
contacts: list["ApolloContact"],
|
|
373
|
+
campaign_id: str | None = None,
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
"""Enrich Apollo contacts and upload to Instantly.
|
|
376
|
+
|
|
377
|
+
For contacts missing email, attempts Apollo person enrichment first.
|
|
378
|
+
Then converts ApolloContact -> InstantlyLead, filters out those
|
|
379
|
+
still without email, and uploads in batches.
|
|
380
|
+
"""
|
|
381
|
+
contacts = list(contacts) # mutable copy
|
|
382
|
+
total_found = len(contacts)
|
|
383
|
+
|
|
384
|
+
# Attempt enrichment for contacts missing email
|
|
385
|
+
enriched_count = 0
|
|
386
|
+
for i, contact in enumerate(contacts):
|
|
387
|
+
if not contact.email and self.apollo_client and contact.linkedin_url:
|
|
388
|
+
try:
|
|
389
|
+
enriched = await self.apollo_client.enrich_person(
|
|
390
|
+
linkedin_url=contact.linkedin_url,
|
|
391
|
+
)
|
|
392
|
+
if enriched and enriched.email:
|
|
393
|
+
contacts[i] = enriched
|
|
394
|
+
enriched_count += 1
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
logger.debug(
|
|
397
|
+
"Enrichment failed for contact %s: %s",
|
|
398
|
+
contact.id,
|
|
399
|
+
exc,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
leads = []
|
|
403
|
+
skipped = 0
|
|
404
|
+
for contact in contacts:
|
|
405
|
+
lead = contact.to_instantly_lead()
|
|
406
|
+
if lead.email:
|
|
407
|
+
leads.append(lead)
|
|
408
|
+
else:
|
|
409
|
+
skipped += 1
|
|
410
|
+
|
|
411
|
+
uploaded = 0
|
|
412
|
+
errors: list[str] = []
|
|
413
|
+
if leads and self.instantly_client:
|
|
414
|
+
for i in range(0, len(leads), self.BULK_BATCH_SIZE):
|
|
415
|
+
batch = leads[i : i + self.BULK_BATCH_SIZE]
|
|
416
|
+
try:
|
|
417
|
+
result = await self.instantly_client.add_leads_bulk(
|
|
418
|
+
campaign_id or "",
|
|
419
|
+
batch,
|
|
420
|
+
)
|
|
421
|
+
uploaded += result.get("added", len(batch))
|
|
422
|
+
except Exception as e:
|
|
423
|
+
errors.append(str(e))
|
|
424
|
+
logger.warning(f"Apollo lead upload batch failed: {e}")
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
"total_found": total_found,
|
|
428
|
+
"enriched": enriched_count,
|
|
429
|
+
"uploaded": uploaded,
|
|
430
|
+
"skipped_no_email": skipped,
|
|
431
|
+
"errors": errors,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async def draft_followups(
|
|
435
|
+
self,
|
|
436
|
+
replies: list[dict],
|
|
437
|
+
context: dict[str, Any] | None = None,
|
|
438
|
+
) -> list[dict]:
|
|
439
|
+
"""Draft follow-up emails for interested/objection replies."""
|
|
440
|
+
from devrel_origin.core.base import strip_markdown_fences
|
|
441
|
+
|
|
442
|
+
drafts: list[dict] = []
|
|
443
|
+
actionable = [r for r in replies if r.get("category") in self.FOLLOWUP_CATEGORIES]
|
|
444
|
+
|
|
445
|
+
if not actionable or not self.llm_client:
|
|
446
|
+
return drafts
|
|
447
|
+
|
|
448
|
+
kb_context = self._kb.search_as_text("outreach follow-up")
|
|
449
|
+
|
|
450
|
+
for reply in actionable:
|
|
451
|
+
try:
|
|
452
|
+
raw = await self.llm_client.generate(
|
|
453
|
+
system_prompt=self.SYSTEM_PROMPT.format(
|
|
454
|
+
product_name=self.product_name,
|
|
455
|
+
),
|
|
456
|
+
user_prompt=self.FOLLOWUP_PROMPT.format(
|
|
457
|
+
category=reply["category"],
|
|
458
|
+
reply_body=reply.get("body", "")[:1000],
|
|
459
|
+
lead_email=reply.get("lead_email", ""),
|
|
460
|
+
kb_context=kb_context,
|
|
461
|
+
),
|
|
462
|
+
temperature=0.5,
|
|
463
|
+
)
|
|
464
|
+
data = json.loads(strip_markdown_fences(raw))
|
|
465
|
+
drafts.append(
|
|
466
|
+
{
|
|
467
|
+
"reply_id": reply.get("reply_id"),
|
|
468
|
+
"email_id": reply.get("email_id"),
|
|
469
|
+
"draft_subject": data.get("subject", ""),
|
|
470
|
+
"draft_body": data.get("body", ""),
|
|
471
|
+
"category": reply["category"],
|
|
472
|
+
"status": "pending_approval",
|
|
473
|
+
}
|
|
474
|
+
)
|
|
475
|
+
except Exception as e:
|
|
476
|
+
logger.warning(
|
|
477
|
+
f"Failed to draft follow-up for {reply.get('reply_id')}: {e}",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return drafts
|
|
481
|
+
|
|
482
|
+
def _parse_asset_type(self, task: str) -> str:
|
|
483
|
+
"""Determine asset type from task string via keyword matching."""
|
|
484
|
+
task_lower = task.lower()
|
|
485
|
+
for asset_type, keywords in self.ASSET_KEYWORDS.items():
|
|
486
|
+
if any(kw in task_lower for kw in keywords):
|
|
487
|
+
return asset_type
|
|
488
|
+
return "general"
|
|
489
|
+
|
|
490
|
+
async def _extract_icp_criteria(self, task: str) -> dict[str, list[str]]:
|
|
491
|
+
"""Extract ICP criteria from a task description via Haiku.
|
|
492
|
+
|
|
493
|
+
Shared between `_execute_prospect` and `_execute_prospect_personalize`.
|
|
494
|
+
Catches both `json.JSONDecodeError` and generic exceptions and returns
|
|
495
|
+
a fully-populated dict with empty defaults so callers can pass it
|
|
496
|
+
straight into Apollo without further None-checks.
|
|
497
|
+
|
|
498
|
+
Normalizes singular keys (industry/title/domain) to plural list-based
|
|
499
|
+
keys, and only passes through known Apollo `search_people` params.
|
|
500
|
+
"""
|
|
501
|
+
from devrel_origin.core.base import strip_markdown_fences
|
|
502
|
+
|
|
503
|
+
empty: dict[str, list[str]] = {
|
|
504
|
+
"industries": [],
|
|
505
|
+
"company_sizes": [],
|
|
506
|
+
"titles": [],
|
|
507
|
+
"locations": [],
|
|
508
|
+
}
|
|
509
|
+
if not self.llm_client:
|
|
510
|
+
return empty
|
|
511
|
+
try:
|
|
512
|
+
raw = await self.llm_client.generate(
|
|
513
|
+
system_prompt=(
|
|
514
|
+
"Extract ICP criteria from the task for an Apollo.io people search."
|
|
515
|
+
),
|
|
516
|
+
user_prompt=(
|
|
517
|
+
f"Extract search criteria from: {task}\n"
|
|
518
|
+
"Return JSON with these optional keys:\n"
|
|
519
|
+
'- "titles": list of job titles (each title separate, no OR)\n'
|
|
520
|
+
'- "industries": list of industry tags\n'
|
|
521
|
+
'- "domains": list of company domains\n'
|
|
522
|
+
'- "min_headcount": integer\n'
|
|
523
|
+
'- "max_headcount": integer\n'
|
|
524
|
+
'Example: {{"titles": ["Head of Developer Relations", '
|
|
525
|
+
'"VP Developer Experience"], "min_headcount": 50, '
|
|
526
|
+
'"max_headcount": 500}}'
|
|
527
|
+
),
|
|
528
|
+
temperature=0.0,
|
|
529
|
+
model="haiku",
|
|
530
|
+
)
|
|
531
|
+
criteria = json.loads(strip_markdown_fences(raw))
|
|
532
|
+
if not isinstance(criteria, dict):
|
|
533
|
+
logger.warning("ICP extraction returned non-dict, using empty criteria")
|
|
534
|
+
return empty
|
|
535
|
+
except json.JSONDecodeError as exc:
|
|
536
|
+
logger.warning("ICP extraction JSON parse failed: %s", exc)
|
|
537
|
+
return empty
|
|
538
|
+
except Exception as exc:
|
|
539
|
+
logger.warning("ICP extraction failed (%s), using empty criteria", exc)
|
|
540
|
+
return empty
|
|
541
|
+
|
|
542
|
+
# Normalise singular LLM keys to plural list-based keys
|
|
543
|
+
normalised: dict[str, Any] = {}
|
|
544
|
+
if "title" in criteria:
|
|
545
|
+
val = criteria["title"]
|
|
546
|
+
normalised["titles"] = [val] if isinstance(val, str) else val
|
|
547
|
+
if "industry" in criteria:
|
|
548
|
+
val = criteria["industry"]
|
|
549
|
+
normalised["industries"] = [val] if isinstance(val, str) else val
|
|
550
|
+
if "domain" in criteria:
|
|
551
|
+
val = criteria["domain"]
|
|
552
|
+
normalised["domains"] = [val] if isinstance(val, str) else val
|
|
553
|
+
if "location" in criteria:
|
|
554
|
+
val = criteria["location"]
|
|
555
|
+
normalised["locations"] = [val] if isinstance(val, str) else val
|
|
556
|
+
# Pass through only known search_people params
|
|
557
|
+
for key in (
|
|
558
|
+
"titles",
|
|
559
|
+
"industries",
|
|
560
|
+
"domains",
|
|
561
|
+
"locations",
|
|
562
|
+
"company_sizes",
|
|
563
|
+
"min_headcount",
|
|
564
|
+
"max_headcount",
|
|
565
|
+
):
|
|
566
|
+
if key in criteria:
|
|
567
|
+
normalised[key] = criteria[key]
|
|
568
|
+
return normalised
|
|
569
|
+
|
|
570
|
+
def _extract_upstream_context(
|
|
571
|
+
self,
|
|
572
|
+
context: dict[str, Any] | None,
|
|
573
|
+
) -> dict[str, Any]:
|
|
574
|
+
"""Extract sales-relevant data from SharedContext."""
|
|
575
|
+
extracted: dict[str, Any] = {
|
|
576
|
+
"competitors": [],
|
|
577
|
+
"threats": [],
|
|
578
|
+
"pain_points": [],
|
|
579
|
+
"issues": [],
|
|
580
|
+
}
|
|
581
|
+
if not context:
|
|
582
|
+
return extracted
|
|
583
|
+
|
|
584
|
+
# Rex competitive data
|
|
585
|
+
if "rex_competitive" in context:
|
|
586
|
+
rex = context["rex_competitive"]
|
|
587
|
+
if isinstance(rex, dict):
|
|
588
|
+
extracted["competitors"] = rex.get("profiles", [])
|
|
589
|
+
extracted["threats"] = rex.get("threats", [])
|
|
590
|
+
|
|
591
|
+
# Iris pain points
|
|
592
|
+
if "iris_themes" in context:
|
|
593
|
+
iris = context["iris_themes"]
|
|
594
|
+
if isinstance(iris, dict):
|
|
595
|
+
extracted["pain_points"] = iris.get("themes", [])
|
|
596
|
+
|
|
597
|
+
# Sage issues
|
|
598
|
+
if "sage_triage" in context:
|
|
599
|
+
sage = context["sage_triage"]
|
|
600
|
+
if isinstance(sage, dict):
|
|
601
|
+
extracted["issues"] = sage.get("issues", [])
|
|
602
|
+
|
|
603
|
+
return extracted
|
|
604
|
+
|
|
605
|
+
async def _research_prospect(
|
|
606
|
+
self,
|
|
607
|
+
contact: "ApolloContact",
|
|
608
|
+
) -> tuple[str, str]:
|
|
609
|
+
"""Research a prospect via web search. Returns (hook, source_url)."""
|
|
610
|
+
if not self.search_tools:
|
|
611
|
+
return ("", "")
|
|
612
|
+
|
|
613
|
+
query = f"{contact.first_name} {contact.last_name} {contact.company_name or ''}"
|
|
614
|
+
results = await self.search_tools.web_search(query, limit=3)
|
|
615
|
+
|
|
616
|
+
if not results:
|
|
617
|
+
if contact.company_name:
|
|
618
|
+
results = await self.search_tools.web_search(
|
|
619
|
+
f"{contact.company_name} news announcement",
|
|
620
|
+
limit=3,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if not results:
|
|
624
|
+
return ("", "")
|
|
625
|
+
|
|
626
|
+
content = await self.search_tools.fetch_url_content(
|
|
627
|
+
results[0].url,
|
|
628
|
+
max_chars=2000,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if not content or not self.llm_client:
|
|
632
|
+
return (results[0].snippet, results[0].url)
|
|
633
|
+
|
|
634
|
+
raw = await self.llm_client.generate(
|
|
635
|
+
system_prompt="Extract a personalization hook for a cold email.",
|
|
636
|
+
user_prompt=(
|
|
637
|
+
f"Person: {contact.first_name} {contact.last_name}, "
|
|
638
|
+
f"{contact.title} at {contact.company_name}\n\n"
|
|
639
|
+
f"Web content:\n{content[:1500]}\n\n"
|
|
640
|
+
"Extract one specific, relevant fact about this person or their "
|
|
641
|
+
"company that could be used as an opening line in a cold email. "
|
|
642
|
+
"Return ONLY the hook sentence, nothing else. If nothing relevant "
|
|
643
|
+
"is found, return 'NO_HOOK'."
|
|
644
|
+
),
|
|
645
|
+
temperature=0.3,
|
|
646
|
+
max_tokens=200,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
hook = raw.strip()
|
|
650
|
+
if hook == "NO_HOOK":
|
|
651
|
+
return ("", "")
|
|
652
|
+
|
|
653
|
+
return (hook, results[0].url)
|
|
654
|
+
|
|
655
|
+
async def _generate_personalized_email(
|
|
656
|
+
self,
|
|
657
|
+
contact: "ApolloContact",
|
|
658
|
+
research_hook: str,
|
|
659
|
+
kb_context: str,
|
|
660
|
+
competitive_context: str,
|
|
661
|
+
) -> dict[str, Any] | None:
|
|
662
|
+
"""Generate a personalized email for one contact. Returns parsed JSON or None."""
|
|
663
|
+
from devrel_origin.core.base import strip_markdown_fences
|
|
664
|
+
|
|
665
|
+
if not self.llm_client:
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
prompt = self.PERSONALIZED_EMAIL_PROMPT.format(
|
|
669
|
+
first_name=contact.first_name,
|
|
670
|
+
last_name=contact.last_name,
|
|
671
|
+
title=contact.title or "Unknown",
|
|
672
|
+
company_name=contact.company_name or "Unknown",
|
|
673
|
+
research_hook=research_hook
|
|
674
|
+
or "No specific hook found — use title and company context.",
|
|
675
|
+
kb_context=kb_context,
|
|
676
|
+
competitive_context=competitive_context,
|
|
677
|
+
product_name=self.product_name,
|
|
678
|
+
sales_cta_url=self.sales_cta_url,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
raw = await self.llm_client.generate(
|
|
683
|
+
system_prompt=self.SYSTEM_PROMPT.format(product_name=self.product_name),
|
|
684
|
+
user_prompt=prompt,
|
|
685
|
+
temperature=0.5,
|
|
686
|
+
max_tokens=1024,
|
|
687
|
+
)
|
|
688
|
+
return json.loads(strip_markdown_fences(raw))
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
logger.warning(f"Email generation failed for {contact.email}: {exc}")
|
|
691
|
+
return None
|
|
692
|
+
|
|
693
|
+
async def _execute_prospect_personalize(
|
|
694
|
+
self,
|
|
695
|
+
task: str,
|
|
696
|
+
asset_type: str,
|
|
697
|
+
context: Optional[dict[str, Any]] = None,
|
|
698
|
+
) -> dict[str, Any]:
|
|
699
|
+
"""Full prospect -> research -> personalize -> upload flow."""
|
|
700
|
+
import asyncio
|
|
701
|
+
|
|
702
|
+
# Step 1: Extract ICP criteria via LLM (shared helper)
|
|
703
|
+
criteria = await self._extract_icp_criteria(task)
|
|
704
|
+
|
|
705
|
+
# Step 2: Apollo search
|
|
706
|
+
try:
|
|
707
|
+
contacts = list(await self.prospect_leads(criteria))
|
|
708
|
+
except Exception as exc:
|
|
709
|
+
logger.warning(f"Apollo search failed: {exc}")
|
|
710
|
+
return {
|
|
711
|
+
"agent": "pax",
|
|
712
|
+
"task": task,
|
|
713
|
+
"asset_type": asset_type,
|
|
714
|
+
"status": "error",
|
|
715
|
+
"error": str(exc),
|
|
716
|
+
"contacts_found": 0,
|
|
717
|
+
}
|
|
718
|
+
if not contacts:
|
|
719
|
+
return {
|
|
720
|
+
"agent": "pax",
|
|
721
|
+
"task": task,
|
|
722
|
+
"asset_type": asset_type,
|
|
723
|
+
"status": "personalized",
|
|
724
|
+
"contacts_found": 0,
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
logger.info(f"Prospecting {len(contacts)} contacts for personalized outreach")
|
|
728
|
+
|
|
729
|
+
# Step 3: Reveal/enrich contacts missing email via person ID or LinkedIn
|
|
730
|
+
enriched_count = 0
|
|
731
|
+
for i, contact in enumerate(contacts):
|
|
732
|
+
if not contact.email and self.apollo_client:
|
|
733
|
+
try:
|
|
734
|
+
enrich_kwargs: dict[str, str] = {}
|
|
735
|
+
if contact.id:
|
|
736
|
+
enrich_kwargs["person_id"] = contact.id
|
|
737
|
+
elif contact.linkedin_url:
|
|
738
|
+
enrich_kwargs["linkedin_url"] = contact.linkedin_url
|
|
739
|
+
if enrich_kwargs:
|
|
740
|
+
enriched = await self.apollo_client.enrich_person(**enrich_kwargs)
|
|
741
|
+
if enriched and enriched.email:
|
|
742
|
+
contacts[i] = enriched
|
|
743
|
+
enriched_count += 1
|
|
744
|
+
except Exception as exc:
|
|
745
|
+
logger.debug(f"Enrichment failed for {contact.first_name}: {exc}")
|
|
746
|
+
|
|
747
|
+
if enriched_count:
|
|
748
|
+
logger.info(f"Revealed {enriched_count}/{len(contacts)} contact emails")
|
|
749
|
+
contacts_with_email = [c for c in contacts if c.email]
|
|
750
|
+
logger.info(f"{len(contacts_with_email)} contacts with email out of {len(contacts)}")
|
|
751
|
+
|
|
752
|
+
# Prepare shared context
|
|
753
|
+
kb_context = self._kb.search_as_text(task)
|
|
754
|
+
upstream = self._extract_upstream_context(context)
|
|
755
|
+
competitive_context = ""
|
|
756
|
+
for c in upstream["competitors"][:5]:
|
|
757
|
+
if isinstance(c, dict):
|
|
758
|
+
competitive_context += f"- {c.get('name', '?')}: {c.get('strengths', [])}\n"
|
|
759
|
+
|
|
760
|
+
# Step 4: Research + generate per contact
|
|
761
|
+
outreach_list: list[dict] = []
|
|
762
|
+
hooks_found = 0
|
|
763
|
+
for contact in contacts_with_email:
|
|
764
|
+
logger.info(
|
|
765
|
+
f"Researching {contact.first_name} {contact.last_name} at {contact.company_name}",
|
|
766
|
+
)
|
|
767
|
+
hook, source_url = await self._research_prospect(contact)
|
|
768
|
+
if hook:
|
|
769
|
+
hooks_found += 1
|
|
770
|
+
|
|
771
|
+
email_data = await self._generate_personalized_email(
|
|
772
|
+
contact,
|
|
773
|
+
hook,
|
|
774
|
+
kb_context,
|
|
775
|
+
competitive_context,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
if email_data:
|
|
779
|
+
outreach_list.append(
|
|
780
|
+
{
|
|
781
|
+
"contact_id": contact.id,
|
|
782
|
+
"first_name": contact.first_name,
|
|
783
|
+
"last_name": contact.last_name,
|
|
784
|
+
"email": contact.email,
|
|
785
|
+
"title": contact.title or "",
|
|
786
|
+
"company_name": contact.company_name or "",
|
|
787
|
+
"research_hook": hook,
|
|
788
|
+
"research_source": source_url,
|
|
789
|
+
"subject": email_data.get("subject", ""),
|
|
790
|
+
"body": email_data.get("body", ""),
|
|
791
|
+
"pain_points_addressed": email_data.get(
|
|
792
|
+
"pain_points_addressed",
|
|
793
|
+
[],
|
|
794
|
+
),
|
|
795
|
+
"sales_psychology": email_data.get("sales_psychology", ""),
|
|
796
|
+
}
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
await asyncio.sleep(1.0)
|
|
800
|
+
|
|
801
|
+
# Step 5-6: Upload to Instantly
|
|
802
|
+
uploaded = 0
|
|
803
|
+
campaign_id = ""
|
|
804
|
+
errors: list[str] = []
|
|
805
|
+
if outreach_list and self.instantly_client:
|
|
806
|
+
leads = [
|
|
807
|
+
InstantlyLead(
|
|
808
|
+
email=o["email"],
|
|
809
|
+
first_name=o["first_name"],
|
|
810
|
+
last_name=o["last_name"],
|
|
811
|
+
company_name=o["company_name"],
|
|
812
|
+
custom_variables={
|
|
813
|
+
"personalized_subject": o["subject"],
|
|
814
|
+
"personalized_body": o["body"],
|
|
815
|
+
"title": o["title"],
|
|
816
|
+
"research_hook": o["research_hook"],
|
|
817
|
+
},
|
|
818
|
+
)
|
|
819
|
+
for o in outreach_list
|
|
820
|
+
]
|
|
821
|
+
campaign_id = (context or {}).get("campaign_id", "")
|
|
822
|
+
# Auto-create campaign if none provided
|
|
823
|
+
if not campaign_id:
|
|
824
|
+
try:
|
|
825
|
+
from datetime import datetime
|
|
826
|
+
|
|
827
|
+
campaign_name = (
|
|
828
|
+
f"{self.product_name} Outreach {datetime.now().strftime('%Y-%m-%d')}"
|
|
829
|
+
)
|
|
830
|
+
# Fetch sending accounts to attach to campaign
|
|
831
|
+
sending_accounts: list[str] = []
|
|
832
|
+
try:
|
|
833
|
+
acct_data = await self.instantly_client._request(
|
|
834
|
+
"GET",
|
|
835
|
+
"/api/v2/accounts",
|
|
836
|
+
params={"limit": 10},
|
|
837
|
+
)
|
|
838
|
+
acct_items = acct_data.get(
|
|
839
|
+
"items", acct_data if isinstance(acct_data, list) else []
|
|
840
|
+
)
|
|
841
|
+
sending_accounts = [a["email"] for a in acct_items if a.get("email")]
|
|
842
|
+
except Exception:
|
|
843
|
+
logger.debug("Could not fetch Instantly sending accounts")
|
|
844
|
+
|
|
845
|
+
campaign = await self.instantly_client.create_campaign(
|
|
846
|
+
name=campaign_name,
|
|
847
|
+
sequences=[
|
|
848
|
+
{
|
|
849
|
+
"steps": [
|
|
850
|
+
{
|
|
851
|
+
"type": "email",
|
|
852
|
+
"delay": 0,
|
|
853
|
+
"variants": [
|
|
854
|
+
{
|
|
855
|
+
"subject": "{{personalized_subject}}",
|
|
856
|
+
"body": "{{personalized_body}}",
|
|
857
|
+
}
|
|
858
|
+
],
|
|
859
|
+
}
|
|
860
|
+
],
|
|
861
|
+
}
|
|
862
|
+
],
|
|
863
|
+
accounts=sending_accounts or None,
|
|
864
|
+
)
|
|
865
|
+
campaign_id = campaign.id
|
|
866
|
+
logger.info(f"Created Instantly campaign: {campaign_name} ({campaign_id})")
|
|
867
|
+
except Exception as e:
|
|
868
|
+
errors.append(f"Campaign creation failed: {e}")
|
|
869
|
+
logger.warning(f"Instantly campaign creation failed: {e}")
|
|
870
|
+
if campaign_id:
|
|
871
|
+
try:
|
|
872
|
+
result = await self.instantly_client.add_leads_bulk(
|
|
873
|
+
campaign_id,
|
|
874
|
+
leads,
|
|
875
|
+
)
|
|
876
|
+
uploaded = result.get("added", len(leads))
|
|
877
|
+
logger.info(f"Uploaded {uploaded} leads to Instantly campaign {campaign_id}")
|
|
878
|
+
except Exception as e:
|
|
879
|
+
errors.append(str(e))
|
|
880
|
+
logger.warning(f"Instantly upload failed: {e}")
|
|
881
|
+
|
|
882
|
+
logger.info(
|
|
883
|
+
f"Personalized outreach complete: {len(outreach_list)} emails "
|
|
884
|
+
f"generated, {hooks_found} hooks found",
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
"agent": "pax",
|
|
889
|
+
"task": task,
|
|
890
|
+
"asset_type": asset_type,
|
|
891
|
+
"status": "personalized",
|
|
892
|
+
"contacts_found": len(contacts),
|
|
893
|
+
"contacts_with_email": len(contacts_with_email),
|
|
894
|
+
"contacts_researched": len(contacts_with_email),
|
|
895
|
+
"hooks_found": hooks_found,
|
|
896
|
+
"emails_generated": len(outreach_list),
|
|
897
|
+
"uploaded_to_instantly": uploaded,
|
|
898
|
+
"campaign_id": campaign_id if campaign_id else None,
|
|
899
|
+
"outreach": outreach_list,
|
|
900
|
+
"enriched": enriched_count,
|
|
901
|
+
"skipped_no_email": len(contacts) - len(contacts_with_email),
|
|
902
|
+
"errors": errors,
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async def _execute_campaign(
|
|
906
|
+
self,
|
|
907
|
+
task: str,
|
|
908
|
+
asset_type: str,
|
|
909
|
+
base_result: dict[str, Any],
|
|
910
|
+
) -> dict[str, Any]:
|
|
911
|
+
"""Handle the instantly_campaign execute path."""
|
|
912
|
+
from devrel_origin.core.base import strip_markdown_fences
|
|
913
|
+
|
|
914
|
+
if self.llm_client is None:
|
|
915
|
+
return {"status": "skipped", "reason": "no_llm_client", "task": task}
|
|
916
|
+
|
|
917
|
+
prompt_text = (
|
|
918
|
+
f"Create a cold email outreach campaign for {self.product_name}. "
|
|
919
|
+
f'Return JSON: {{"sequences": [{{"subject": "...", '
|
|
920
|
+
f'"body": "...", "delay_days": N}}]}}'
|
|
921
|
+
)
|
|
922
|
+
try:
|
|
923
|
+
raw = await self.llm_client.generate(
|
|
924
|
+
system_prompt=self.SYSTEM_PROMPT.format(
|
|
925
|
+
product_name=self.product_name,
|
|
926
|
+
),
|
|
927
|
+
user_prompt=prompt_text,
|
|
928
|
+
)
|
|
929
|
+
data = json.loads(strip_markdown_fences(raw))
|
|
930
|
+
sequences = data.get("sequences", [])
|
|
931
|
+
campaign = await self.instantly_client.create_campaign(
|
|
932
|
+
name=f"{self.product_name} - Outreach",
|
|
933
|
+
sequences=sequences,
|
|
934
|
+
)
|
|
935
|
+
return {
|
|
936
|
+
"agent": "pax",
|
|
937
|
+
"task": task,
|
|
938
|
+
"asset_type": asset_type,
|
|
939
|
+
"status": "campaign_created",
|
|
940
|
+
"campaign_id": campaign.id,
|
|
941
|
+
"campaign_name": campaign.name,
|
|
942
|
+
}
|
|
943
|
+
except Exception as exc:
|
|
944
|
+
logger.warning(f"Campaign creation failed: {exc}")
|
|
945
|
+
base_result["prompt_used"] = prompt_text[:500]
|
|
946
|
+
return base_result
|
|
947
|
+
|
|
948
|
+
async def _classify_email(self, email: Any) -> dict:
|
|
949
|
+
"""Classify a single email reply using LLM."""
|
|
950
|
+
from devrel_origin.core.base import strip_markdown_fences
|
|
951
|
+
|
|
952
|
+
base = {
|
|
953
|
+
"reply_id": email.id,
|
|
954
|
+
"email_id": email.id,
|
|
955
|
+
"body": email.body,
|
|
956
|
+
"lead_email": email.lead_email,
|
|
957
|
+
}
|
|
958
|
+
if not self.llm_client:
|
|
959
|
+
return {**base, "category": "not_now"}
|
|
960
|
+
try:
|
|
961
|
+
raw = await self.llm_client.generate(
|
|
962
|
+
system_prompt="You classify cold outreach email replies for sales triage.",
|
|
963
|
+
user_prompt=self.TRIAGE_PROMPT.format(
|
|
964
|
+
reply_body=email.body[:1000],
|
|
965
|
+
),
|
|
966
|
+
temperature=0.0,
|
|
967
|
+
max_tokens=256,
|
|
968
|
+
model="haiku",
|
|
969
|
+
)
|
|
970
|
+
data = json.loads(strip_markdown_fences(raw))
|
|
971
|
+
return {**base, "category": data.get("category", "not_now")}
|
|
972
|
+
except Exception:
|
|
973
|
+
return {**base, "category": "not_now"}
|
|
974
|
+
|
|
975
|
+
async def _execute_triage(
|
|
976
|
+
self,
|
|
977
|
+
task: str,
|
|
978
|
+
asset_type: str,
|
|
979
|
+
context: Optional[dict[str, Any]],
|
|
980
|
+
) -> dict[str, Any]:
|
|
981
|
+
"""Handle the triage_replies execute path."""
|
|
982
|
+
emails = await self.instantly_client.list_emails(is_reply=True)
|
|
983
|
+
classified = [await self._classify_email(e) for e in emails]
|
|
984
|
+
drafts = await self.draft_followups(classified, context)
|
|
985
|
+
categories: dict[str, int] = {}
|
|
986
|
+
for c in classified:
|
|
987
|
+
categories[c["category"]] = categories.get(c["category"], 0) + 1
|
|
988
|
+
return {
|
|
989
|
+
"agent": "pax",
|
|
990
|
+
"task": task,
|
|
991
|
+
"asset_type": asset_type,
|
|
992
|
+
"status": "triaged",
|
|
993
|
+
"total_replies": len(classified),
|
|
994
|
+
"categories": categories,
|
|
995
|
+
"drafts": drafts,
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async def _execute_prospect(
|
|
999
|
+
self,
|
|
1000
|
+
task: str,
|
|
1001
|
+
asset_type: str,
|
|
1002
|
+
context: Optional[dict[str, Any]] = None,
|
|
1003
|
+
) -> dict[str, Any]:
|
|
1004
|
+
"""Handle the prospect_leads execute path."""
|
|
1005
|
+
criteria = await self._extract_icp_criteria(task)
|
|
1006
|
+
try:
|
|
1007
|
+
contacts = await self.prospect_leads(criteria)
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
logger.warning(f"Apollo search failed: {exc}")
|
|
1010
|
+
return {
|
|
1011
|
+
"agent": "pax",
|
|
1012
|
+
"task": task,
|
|
1013
|
+
"asset_type": asset_type,
|
|
1014
|
+
"status": "error",
|
|
1015
|
+
"error": str(exc),
|
|
1016
|
+
"contacts_found": 0,
|
|
1017
|
+
}
|
|
1018
|
+
upload_result: dict[str, Any] = {}
|
|
1019
|
+
if contacts and self.instantly_client:
|
|
1020
|
+
upload_result = await self.enrich_and_upload(
|
|
1021
|
+
contacts,
|
|
1022
|
+
campaign_id=(context or {}).get("campaign_id"),
|
|
1023
|
+
)
|
|
1024
|
+
return {
|
|
1025
|
+
"agent": "pax",
|
|
1026
|
+
"task": task,
|
|
1027
|
+
"asset_type": asset_type,
|
|
1028
|
+
"status": "prospected",
|
|
1029
|
+
"contacts_found": len(contacts),
|
|
1030
|
+
**upload_result,
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async def _execute_enrich_upload(
|
|
1034
|
+
self,
|
|
1035
|
+
task: str,
|
|
1036
|
+
asset_type: str,
|
|
1037
|
+
context: Optional[dict[str, Any]],
|
|
1038
|
+
) -> dict[str, Any]:
|
|
1039
|
+
"""Handle the enrich_upload execute path."""
|
|
1040
|
+
raw_contacts = (context or {}).get("apollo_contacts", [])
|
|
1041
|
+
from devrel_origin.tools.apollo_client import ApolloContact as AC
|
|
1042
|
+
|
|
1043
|
+
contacts = [
|
|
1044
|
+
AC(
|
|
1045
|
+
id=c.get("id", ""),
|
|
1046
|
+
first_name=c.get("first_name", ""),
|
|
1047
|
+
last_name=c.get("last_name", ""),
|
|
1048
|
+
email=c.get("email"),
|
|
1049
|
+
title=c.get("title"),
|
|
1050
|
+
company_name=c.get("company_name"),
|
|
1051
|
+
company_domain=c.get("company_domain"),
|
|
1052
|
+
linkedin_url=c.get("linkedin_url"),
|
|
1053
|
+
phone=c.get("phone"),
|
|
1054
|
+
)
|
|
1055
|
+
for c in raw_contacts
|
|
1056
|
+
]
|
|
1057
|
+
campaign_id = (context or {}).get("campaign_id")
|
|
1058
|
+
result = await self.enrich_and_upload(contacts, campaign_id=campaign_id)
|
|
1059
|
+
return {
|
|
1060
|
+
"agent": "pax",
|
|
1061
|
+
"task": task,
|
|
1062
|
+
"asset_type": asset_type,
|
|
1063
|
+
"status": "enriched_and_uploaded",
|
|
1064
|
+
**result,
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
def _build_asset_prompt(
|
|
1068
|
+
self,
|
|
1069
|
+
task: str,
|
|
1070
|
+
asset_type: str,
|
|
1071
|
+
upstream: dict[str, Any],
|
|
1072
|
+
kb_context: str,
|
|
1073
|
+
) -> str:
|
|
1074
|
+
"""Build the LLM prompt for generic asset generation."""
|
|
1075
|
+
competitive_section = ""
|
|
1076
|
+
if upstream["competitors"]:
|
|
1077
|
+
competitive_section = "Competitor profiles:\n"
|
|
1078
|
+
for c in upstream["competitors"][:5]:
|
|
1079
|
+
if isinstance(c, dict):
|
|
1080
|
+
competitive_section += (
|
|
1081
|
+
f"- {c.get('name', '?')}: "
|
|
1082
|
+
f"strengths={c.get('strengths', [])}, "
|
|
1083
|
+
f"weaknesses={c.get('weaknesses', [])}\n"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
pain_section = ""
|
|
1087
|
+
if upstream["pain_points"]:
|
|
1088
|
+
pain_section = "Developer pain points:\n"
|
|
1089
|
+
for pp in upstream["pain_points"][:5]:
|
|
1090
|
+
if isinstance(pp, dict):
|
|
1091
|
+
pain_section += (
|
|
1092
|
+
f"- {pp.get('title', '?')} "
|
|
1093
|
+
f"(severity: {pp.get('severity', '?')}): "
|
|
1094
|
+
f"{pp.get('description', '')[:200]}\n"
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
return f"""Task: {task}
|
|
1098
|
+
Asset type: {asset_type}
|
|
1099
|
+
|
|
1100
|
+
## Knowledge Base
|
|
1101
|
+
{kb_context if kb_context else "No relevant KB docs found."}
|
|
1102
|
+
|
|
1103
|
+
## Competitive Intelligence
|
|
1104
|
+
{competitive_section if competitive_section else "No competitive data available."}
|
|
1105
|
+
|
|
1106
|
+
## Developer Pain Points
|
|
1107
|
+
{pain_section if pain_section else "No pain point data available."}
|
|
1108
|
+
|
|
1109
|
+
## Instructions
|
|
1110
|
+
Generate the requested sales asset ({asset_type}). Ground all claims in the
|
|
1111
|
+
knowledge base and competitive data above. Include specific features, real
|
|
1112
|
+
pain points, and concrete CTAs. Do NOT invent capabilities not in the KB.
|
|
1113
|
+
|
|
1114
|
+
Return a JSON object with the generated asset content."""
|
|
1115
|
+
|
|
1116
|
+
async def execute(
|
|
1117
|
+
self,
|
|
1118
|
+
task: str,
|
|
1119
|
+
context: Optional[dict[str, Any]] = None,
|
|
1120
|
+
) -> dict[str, Any]:
|
|
1121
|
+
"""
|
|
1122
|
+
Execute a sales enablement task.
|
|
1123
|
+
|
|
1124
|
+
Determines asset type from task string, gathers upstream context,
|
|
1125
|
+
and generates the asset via LLM.
|
|
1126
|
+
"""
|
|
1127
|
+
logger.info(f"Pax executing: {task[:80]}...")
|
|
1128
|
+
|
|
1129
|
+
asset_type = self._parse_asset_type(task)
|
|
1130
|
+
upstream = self._extract_upstream_context(context)
|
|
1131
|
+
kb_context = self._kb.search_as_text(task)
|
|
1132
|
+
prompt = self._build_asset_prompt(task, asset_type, upstream, kb_context)
|
|
1133
|
+
|
|
1134
|
+
base_result: dict[str, Any] = {
|
|
1135
|
+
"agent": "pax",
|
|
1136
|
+
"task": task,
|
|
1137
|
+
"asset_type": asset_type,
|
|
1138
|
+
"status": "generated",
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
# Handle Apollo-specific asset types
|
|
1142
|
+
if asset_type == "prospect_personalize" and self.apollo_client:
|
|
1143
|
+
return await self._execute_prospect_personalize(task, asset_type, context)
|
|
1144
|
+
|
|
1145
|
+
if asset_type == "prospect_leads" and self.apollo_client:
|
|
1146
|
+
return await self._execute_prospect(task, asset_type, context)
|
|
1147
|
+
|
|
1148
|
+
if asset_type == "enrich_upload" and self.apollo_client:
|
|
1149
|
+
return await self._execute_enrich_upload(task, asset_type, context)
|
|
1150
|
+
|
|
1151
|
+
# Handle Instantly-specific asset types
|
|
1152
|
+
if asset_type == "instantly_campaign" and self.instantly_client and self.llm_client:
|
|
1153
|
+
return await self._execute_campaign(task, asset_type, base_result)
|
|
1154
|
+
|
|
1155
|
+
if asset_type == "lead_upload" and self.instantly_client:
|
|
1156
|
+
return {
|
|
1157
|
+
"agent": "pax",
|
|
1158
|
+
"task": task,
|
|
1159
|
+
"asset_type": asset_type,
|
|
1160
|
+
"status": "uploaded",
|
|
1161
|
+
**(
|
|
1162
|
+
await self.upload_leads(
|
|
1163
|
+
campaign_id=context.get("campaign_id", "") if context else "",
|
|
1164
|
+
context=context,
|
|
1165
|
+
)
|
|
1166
|
+
),
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if asset_type == "triage_replies" and self.instantly_client:
|
|
1170
|
+
return await self._execute_triage(task, asset_type, context)
|
|
1171
|
+
|
|
1172
|
+
if self.llm_client:
|
|
1173
|
+
try:
|
|
1174
|
+
# Map Pax's asset_type to the pipeline's content_type vocabulary.
|
|
1175
|
+
# battle_card matches verbatim; the rest fall back to cold_email
|
|
1176
|
+
# readability targets (one_pager → landing_page density).
|
|
1177
|
+
pipeline_content_type = {
|
|
1178
|
+
"battle_card": "battle_card",
|
|
1179
|
+
"one_pager": "landing_page",
|
|
1180
|
+
"outreach": "cold_email",
|
|
1181
|
+
"nurture": "cold_email",
|
|
1182
|
+
"objection": "cold_email",
|
|
1183
|
+
"general": "cold_email",
|
|
1184
|
+
}.get(asset_type, "cold_email")
|
|
1185
|
+
raw, strengths, issues = await generate_with_pipeline(
|
|
1186
|
+
llm_client=self.llm_client,
|
|
1187
|
+
system_prompt=self.SYSTEM_PROMPT.format(
|
|
1188
|
+
product_name=self.product_name,
|
|
1189
|
+
),
|
|
1190
|
+
user_prompt=prompt,
|
|
1191
|
+
content_type=pipeline_content_type,
|
|
1192
|
+
logger=logger,
|
|
1193
|
+
)
|
|
1194
|
+
base_result["content"] = raw
|
|
1195
|
+
base_result["revision"] = {
|
|
1196
|
+
"strengths": strengths,
|
|
1197
|
+
"issues": issues,
|
|
1198
|
+
}
|
|
1199
|
+
except Exception as exc:
|
|
1200
|
+
logger.warning(f"LLM generation failed: {exc}")
|
|
1201
|
+
base_result["prompt_used"] = prompt[:500]
|
|
1202
|
+
else:
|
|
1203
|
+
base_result["prompt_used"] = prompt[:500]
|
|
1204
|
+
|
|
1205
|
+
return base_result
|