-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.py
More file actions
436 lines (359 loc) · 16.6 KB
/
Copy pathmain.py
File metadata and controls
436 lines (359 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
import sys
import argparse
import logging
import time
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table
from rich.live import Live
from src.graph import create_graph
from src.state import AgentState
from src.analytics import log_event, format_summary
from src.tools_gmail import list_drafts, get_draft_details, send_draft, list_starred_threads, get_thread_history, get_thread_metadata, create_draft_reply
from src.nodes import evaluate_starred_thread
from config.settings import MAX_REFINEMENT_ITERATIONS, RESUME_PDF_PATH
logger = logging.getLogger(__name__)
console = Console()
def display_draft(state: AgentState):
console.clear()
console.print(Panel.fit(
f"[bold blue]Recipient:[/bold blue] {state['recipient_name']} ({state['company_name']})\n"
f"[bold blue]Position:[/bold blue] {state['position']}",
title=f"Lead Information (Row {state.get('row_index', '?')})"
))
console.print("\n")
console.print(Panel(
f"[bold]Subject:[/bold] {state.get('email_subject', 'N/A')}\n"
"---"
f"{state.get('email_body', 'N/A')}",
title="Email Draft"
))
def display_variants(state: AgentState) -> None:
"""Display A/B subject line variants and let user select one."""
variants = state.get('subject_variants', [])
if not variants or len(variants) <= 1:
return
console.print(Panel(
"\n".join(f" [bold cyan][{i}][/bold cyan] {v}" for i, v in enumerate(variants, 1)),
title="[bold]A/B Subject Line Variants[/bold]",
border_style="cyan",
))
choice = Prompt.ask(
"Select a subject variant",
choices=[str(i) for i in range(1, len(variants) + 1)],
default="1",
)
selected_idx = int(choice) - 1
selected_subject = variants[selected_idx]
console.print(f"[green]Selected variant {selected_idx + 1}:[/green] {selected_subject}\n")
# Log the A/B choice for analytics
log_event(
"variant_selected",
recipient=state.get('recipient_name', ''),
company=state.get('company_name', ''),
data={"variant_index": selected_idx, "selected_subject": selected_subject},
)
return selected_subject
# ---------------------------------------------------------------------------
# Send Drafts Mode
# ---------------------------------------------------------------------------
SEND_INTERVAL = 20 # seconds between sends
def send_drafts_loop(limit: int) -> None:
"""Fetch recent Gmail drafts and send them at 20-second intervals.
Args:
limit: Max number of drafts to send. 0 means unlimited (until exhausted).
"""
mode_label = f"up to {limit}" if limit > 0 else "all available"
console.print(Panel(
f"[bold magenta]Send Drafts Mode[/bold magenta]\n"
f"Sending [bold]{mode_label}[/bold] recent drafts at {SEND_INTERVAL}s intervals.\n"
f"Press [bold red]Ctrl+C[/bold red] to stop.",
expand=False,
))
# Fetch drafts
fetch_count = limit if limit > 0 else 500 # reasonable upper bound
console.print(f"\n[dim]Fetching drafts...[/dim]")
drafts = list_drafts(max_results=fetch_count)
if not drafts:
console.print("[yellow]No drafts found. Nothing to send.[/yellow]")
return
total = len(drafts)
if limit > 0:
total = min(total, limit)
drafts = drafts[:total]
console.print(f"[green]Found {len(drafts)} draft(s) to send.[/green]\n")
sent_count = 0
try:
for i, draft_meta in enumerate(drafts, 1):
draft_id = draft_meta["id"]
# Fetch header details for display
try:
details = get_draft_details(draft_id)
headers = {
h["name"]: h["value"]
for h in details.get("message", {}).get("payload", {}).get("headers", [])
}
to_field = headers.get("To", "[unknown]")
subject = headers.get("Subject", "[no subject]")
except Exception:
to_field = "[unknown]"
subject = "[could not fetch details]"
console.print(
f"[bold cyan][{i}/{total}][/bold cyan] "
f"Sending → [bold]{to_field}[/bold] · {subject}"
)
# Send
try:
send_draft(draft_id)
sent_count += 1
console.print(f" [green]✓ Sent successfully[/green]")
except Exception as e:
console.print(f" [red]✗ Failed: {e}[/red]")
# Sleep between sends (skip after the last one)
if i < total:
console.print(f" [dim]Waiting {SEND_INTERVAL}s...[/dim]")
time.sleep(SEND_INTERVAL)
except KeyboardInterrupt:
console.print(f"\n[bold red]Interrupted![/bold red]")
console.print(f"\n[bold green]Done. Sent {sent_count}/{total} draft(s).[/bold green]")
# ---------------------------------------------------------------------------
# Starred Emails Mode
# ---------------------------------------------------------------------------
def starred_emails_loop(limit: int) -> None:
"""Iterate over starred emails, evaluate them natively, and suggest follow-up drafts."""
mode_label = f"up to {limit}" if limit > 0 else "all available"
console.print(Panel(
f"[bold yellow]Starred Emails Mode[/bold yellow]\n"
f"Processing [bold]{mode_label}[/bold] starred emails.\n"
f"Press [bold red]Ctrl+C[/bold red] to stop.",
expand=False,
))
# Fetch starred threads
fetch_count = limit if limit > 0 else 50
console.print(f"\n[dim]Fetching starred threads...[/dim]")
threads = list_starred_threads(max_results=fetch_count)
if not threads:
console.print("[yellow]No starred threads found.[/yellow]")
return
total = len(threads)
if limit > 0:
total = min(total, limit)
threads = threads[:total]
console.print(f"[green]Found {len(threads)} starred thread(s) to process.[/green]\n")
try:
for i, thread_meta in enumerate(threads, 1):
thread_id = thread_meta["id"]
console.print(f"[bold cyan]--- [{i}/{total}] Processing Thread {thread_id} ---[/bold cyan]")
# Get metadata & history
meta = get_thread_metadata(thread_id)
chat_history = get_thread_history(thread_id)
# Evaluate
console.print("[dim]Evaluating thread with LLM...[/dim]")
evaluation = evaluate_starred_thread(chat_history)
# Display
console.print(Panel.fit(
f"[bold blue]Subject:[/bold blue] {meta['subject']}\n"
f"[bold blue]Last Date:[/bold blue] {meta['last_date']}\n"
f"[bold blue]Messages:[/bold blue] {meta['msg_count']}\n"
f"---\n"
f"[bold]Follow up needed?[/bold] [{'green' if evaluation.follow_up else 'red'}]{evaluation.follow_up}[/]\n"
f"[bold]Confidence:[/bold] {evaluation.confidence_score}%\n"
f"[bold]Reason:[/bold] {evaluation.reason}",
title="Thread Evaluation"
))
if evaluation.follow_up and evaluation.suggested_draft:
console.print(Panel(
evaluation.suggested_draft,
title="Suggested Draft"
))
action = Prompt.ask(
"[a] Approve & Draft / [s] Skip",
choices=["a", "s"],
default="a"
)
if action.lower() == 'a':
console.print("[dim]Creating draft...[/dim]")
try:
# Only attach resume safely if it exists
resume_pdf_path = str(RESUME_PDF_PATH) if RESUME_PDF_PATH and hasattr(RESUME_PDF_PATH, 'is_file') and RESUME_PDF_PATH.is_file() else None
create_draft_reply(
thread_id=thread_id,
body=evaluation.suggested_draft,
attachment_path=resume_pdf_path
)
console.print("[green]✓ Draft created successfully.[/green]\n")
except Exception as e:
console.print(f"[red]✗ Failed to create draft: {e}[/red]\n")
else:
console.print("[yellow]Skipping thread.[/yellow]\n")
else:
console.print("[dim]No follow-up suggested by LLM.[/dim]")
if i < total:
Prompt.ask("Press Enter to continue to next thread...", default="")
console.print("")
except KeyboardInterrupt:
console.print(f"\n[bold red]Interrupted![/bold red]")
console.print(f"\n[bold green]Done processing starred threads.[/bold green]")
def main():
parser = argparse.ArgumentParser(description="ACE: Agentic Cold Emailer")
group = parser.add_mutually_exclusive_group()
group.add_argument("--follow-ups", type=int, choices=[1, 2], help="Run follow-up sequence (1 or 2)")
group.add_argument(
"--send-drafts", type=int, nargs="?", const=0, default=None, metavar="N",
help="Send recent Gmail drafts at 20s intervals. Optionally specify N to limit count."
)
group.add_argument(
"--starred", type=int, nargs="?", const=0, default=None, metavar="N",
help="Process starred emails and draft AI-suggested follow-ups. Optionally specify N to limit count."
)
args = parser.parse_args()
console.print(Panel("[bold green]ACE: Agentic Cold Emailer[/bold green]", expand=False))
if args.starred is not None:
starred_emails_loop(args.starred)
return
# Dispatch to send-drafts mode if requested
if args.send_drafts is not None:
send_drafts_loop(args.send_drafts)
return
is_followup = args.follow_ups is not None
followup_num = args.follow_ups if is_followup else 0
if is_followup:
console.print(f"\n[bold yellow]FOLLOW-UP MODE: Stage {followup_num}[/bold yellow]")
run_mode = "auto_draft" # Follow-ups are usually bulk drafted
else:
# Mode Selection
console.print("\n[bold]Select Execution Mode:[/bold]")
console.print("1. [bold cyan]Interactive (HITL)[/bold cyan]: Review and approve each email before sending.")
console.print("2. [bold magenta]Automatic (Draft Mode)[/bold magenta]: Automatically create drafts for all leads to review later in Gmail.")
mode_choice = Prompt.ask("Enter choice", choices=["1", "2"], default="1")
is_autonomous = (mode_choice == "2")
run_mode = "auto_draft" if is_autonomous else "interactive"
is_autonomous = (run_mode == "auto_draft")
if is_autonomous and not is_followup:
console.print(f"\n[bold magenta]Starting in Automatic Draft Mode.[/bold magenta] All emails will be saved to 'Drafts'.")
elif not is_followup:
console.print(f"\n[bold cyan]Starting in Interactive Mode.[/bold cyan]")
# Create Graph with conditional interrupt
graph = create_graph(autonomous=is_autonomous)
config = {"configurable": {"thread_id": "ace_session"}}
console.print("Starting workflow...")
# Run loop
first_run = True
while True:
state = graph.get_state(config)
if not state.values or first_run:
if not state.values:
initial_input = {
"mode": run_mode,
"is_followup_mode": is_followup,
"followup_number": followup_num
}
graph.invoke(initial_input, config)
else:
graph.update_state(config, {
"mode": run_mode,
"is_followup_mode": is_followup,
"followup_number": followup_num
})
if first_run and is_autonomous:
graph.invoke(None, config)
first_run = False
# Refresh state after invoke
state = graph.get_state(config)
current_state = state.values
if not current_state:
break
if current_state.get("status") == "end":
console.print("\n[bold green]All leads processed. Goodbye![/bold green]")
# Print analytics summary
console.print(f"\n{format_summary()}")
break
# INTERACTIVE MODE LOGIC
if not is_autonomous:
if state.next and "review" in state.next:
display_draft(current_state)
# A/B Variant Selection
selected_subject = display_variants(current_state)
if selected_subject:
graph.update_state(config, {"email_subject": selected_subject})
# Refresh state
state = graph.get_state(config)
current_state = state.values
# Handle Email Selection if needed
candidate_emails = current_state.get('candidate_emails', [])
selected_emails = current_state.get('selected_emails')
if len(candidate_emails) > 1 and not selected_emails:
console.print(Panel(
"[bold yellow]WARNING:[/bold yellow] Multiple emails found for this lead.",
border_style="yellow"
))
for i, email in enumerate(candidate_emails, 1):
console.print(f" [{i}] {email}")
choice = Prompt.ask(
"Target which email? (Type 'all' for all, or '1', '2'...)",
default="all"
)
if choice.lower() == 'all':
selected_emails = candidate_emails
else:
try:
idx = int(choice) - 1
if 0 <= idx < len(candidate_emails):
selected_emails = [candidate_emails[idx]]
else:
console.print("[red]Invalid index, defaulting to first.[/red]")
selected_emails = [candidate_emails[0]]
except ValueError:
console.print("[red]Invalid input, defaulting to first.[/red]")
selected_emails = [candidate_emails[0]]
console.print(f"[green]Selected:[/green] {', '.join(selected_emails)}\n")
elif len(candidate_emails) == 1:
selected_emails = [candidate_emails[0]]
# Iteration guard — restrict options if max reached
iteration_count = current_state.get('iteration_count', 0)
if iteration_count >= MAX_REFINEMENT_ITERATIONS:
console.print(
f"[bold yellow]Max refinements ({MAX_REFINEMENT_ITERATIONS}) reached. "
f"You can only approve or skip.[/bold yellow]"
)
action = Prompt.ask(
"[y] Approve / [s] Skip",
default="y"
)
else:
action = Prompt.ask(
"[y] Approve / [s] Skip / [type feedback] Refine",
default="y"
)
if action.lower() == 'y':
graph.update_state(config, {
"status": "approved",
"selected_emails": selected_emails
})
elif action.lower() in ['s', 'skip']:
graph.update_state(config, {
"status": "skipped",
"selected_emails": None
})
else:
graph.update_state(config, {"user_feedback": action, "status": "refining"})
# Resume execution
logger.info("Resuming workflow...")
graph.invoke(None, config)
else:
graph.invoke(None, config)
# AUTOMATIC MODE LOGIC
else:
if current_state.get("status") != "end":
graph.invoke(None, config)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
console.print("\n[bold red]Interrupted by user. Exiting...[/bold red]")
console.print(f"\n{format_summary()}")
sys.exit(0)
except Exception as e:
console.print(f"\n[bold red]Error:[/bold red] {str(e)}")
sys.exit(1)