openoutsend 0.1.16__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.
- cold_outreach/README.md +71 -0
- cold_outreach/__init__.py +0 -0
- cold_outreach/__main__.py +172 -0
- cold_outreach/core/__init__.py +0 -0
- cold_outreach/core/agents/__init__.py +0 -0
- cold_outreach/core/agents/outreach.py +190 -0
- cold_outreach/core/agents/prompt.py +57 -0
- cold_outreach/core/business_time.py +47 -0
- cold_outreach/core/conf.py +135 -0
- cold_outreach/core/llm.py +235 -0
- cold_outreach/core/operator.py +68 -0
- cold_outreach/core/sending_window.py +63 -0
- cold_outreach/core/templates/prompts/outreach_agent.j2 +98 -0
- cold_outreach/docs/outreach_agent.md +59 -0
- cold_outreach/docs/template-variables.md +18 -0
- cold_outreach/docs/templating.md +12 -0
- cold_outreach/emails/__init__.py +0 -0
- cold_outreach/emails/admin.py +63 -0
- cold_outreach/emails/apps.py +8 -0
- cold_outreach/emails/classify.py +192 -0
- cold_outreach/emails/delivery_policy.py +250 -0
- cold_outreach/emails/mail_pass.py +33 -0
- cold_outreach/emails/migrations/0001_initial.py +37 -0
- cold_outreach/emails/migrations/0002_mailbox_signature.py +23 -0
- cold_outreach/emails/migrations/0003_alter_mailbox_daily_limit.py +36 -0
- cold_outreach/emails/migrations/0004_alter_mailbox_daily_limit_sendverdict.py +66 -0
- cold_outreach/emails/migrations/0005_mailbox_unsub_scan_uid_and_more.py +23 -0
- cold_outreach/emails/migrations/0006_mailbox_next_send_at.py +18 -0
- cold_outreach/emails/migrations/0007_deliveryevent_foldercoverage_message_thread_and_more.py +283 -0
- cold_outreach/emails/migrations/__init__.py +0 -0
- cold_outreach/emails/models/__init__.py +30 -0
- cold_outreach/emails/models/mailbox.py +223 -0
- cold_outreach/emails/models/maillog.py +263 -0
- cold_outreach/emails/parsing.py +178 -0
- cold_outreach/emails/project.py +148 -0
- cold_outreach/emails/report.py +39 -0
- cold_outreach/emails/sender.py +296 -0
- cold_outreach/emails/smtp.py +47 -0
- cold_outreach/emails/steps/__init__.py +0 -0
- cold_outreach/emails/steps/reply.py +162 -0
- cold_outreach/emails/steps/send.py +92 -0
- cold_outreach/emails/sync.py +267 -0
- cold_outreach/emails/threads.py +92 -0
- cold_outreach/emails/warmth.py +255 -0
- cold_outreach/errors.py +15 -0
- cold_outreach/first_run.py +239 -0
- cold_outreach/leads/__init__.py +11 -0
- cold_outreach/leads/apps.py +7 -0
- cold_outreach/leads/campaigns.py +87 -0
- cold_outreach/leads/ingest.py +194 -0
- cold_outreach/leads/migrations/0001_initial.py +134 -0
- cold_outreach/leads/migrations/__init__.py +0 -0
- cold_outreach/leads/models.py +227 -0
- cold_outreach/leads/pools.py +76 -0
- cold_outreach/leads/summaries.py +175 -0
- cold_outreach/leads/suppression.py +67 -0
- cold_outreach/send_pass.py +148 -0
- cold_outreach/settings.py +74 -0
- openoutsend-0.1.16.dist-info/METADATA +163 -0
- openoutsend-0.1.16.dist-info/RECORD +63 -0
- openoutsend-0.1.16.dist-info/WHEEL +4 -0
- openoutsend-0.1.16.dist-info/entry_points.txt +2 -0
- openoutsend-0.1.16.dist-info/licenses/LICENSE +9 -0
cold_outreach/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# cold_outreach — ported from OpenOutreach, not yet wired up
|
|
2
|
+
|
|
3
|
+
This directory is **parked code**, and it is now the whole of this repo. Nothing imports it,
|
|
4
|
+
nothing runs it, and it is not on `INSTALLED_APPS`. It is the cold-outreach half of
|
|
5
|
+
[OpenOutreach](https://github.com/eracle/OpenOutreach), moved here whole so it can be integrated
|
|
6
|
+
deliberately rather than rewritten from memory.
|
|
7
|
+
|
|
8
|
+
## Why it moved
|
|
9
|
+
|
|
10
|
+
OpenOutreach became a **lead finder**: product description in, qualified leads out, with the reason
|
|
11
|
+
each one was chosen, handed over as CSV. Sending is a specialism it was not good at, and a good lead
|
|
12
|
+
list ruined by a bad opener reads to the buyer as a bad lead list. So the sending half comes here,
|
|
13
|
+
where the whole job is the message.
|
|
14
|
+
|
|
15
|
+
The boundary between the two is **one-way and public**: OpenOutreach exports leads, and nothing flows
|
|
16
|
+
back. Whatever this code becomes, it must read that same export — no shared model, no direct import,
|
|
17
|
+
no DB join. OpenOutSend gets no privileged path that Instantly or Smartlead would not get.
|
|
18
|
+
|
|
19
|
+
The design decisions behind that split live in `roadmap/p1-e3-leadfinder-sequencer-boundary.md` in the
|
|
20
|
+
`openoutreach-docs` repo. Read it before integrating; several decisions were reversed after the fact
|
|
21
|
+
and the reasoning is recorded there rather than in either repo's git history.
|
|
22
|
+
|
|
23
|
+
## What is here
|
|
24
|
+
|
|
25
|
+
Layout mirrors OpenOutreach's, so the original import paths still read correctly.
|
|
26
|
+
|
|
27
|
+
| path | what it is |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `emails/models/mailbox.py` | `Mailbox` — one SMTP sending inbox, plus the pool-level pacing manager (`free_for_first_email`, `remaining_today`) and the measured daily cap. |
|
|
30
|
+
| `emails/models/maillog.py` | `Message` / `Thread` / `DeliveryEvent` / `FolderCoverage` — the mail log. The record and its interpretation are separate objects; `processed_at IS NULL` is the third state. |
|
|
31
|
+
| `emails/sync.py`, `classify.py`, `project.py`, `mail_pass.py` | The mail pass, in its three ordered jobs: sync (IMAP → rows, the only network step), classify (pure, versioned, over stored bytes), project (bounces → `DeliveryEvent`, opt-outs → suppression). |
|
|
32
|
+
| `emails/threads.py` | Union-find over Message-IDs, so a reply carrying only `In-Reply-To` still reaches its deal. |
|
|
33
|
+
| `emails/parsing.py` | Message parsing helpers. |
|
|
34
|
+
| `emails/sender.py`, `smtp.py` | The send path: body → signature → opt-out line → attribution, `List-Unsubscribe`, SMTP auth. |
|
|
35
|
+
| `emails/warmth.py`, `delivery_policy.py`, `report.py` | The three send guards — hours, rate, volume — and the receiver feedback that moves them. The daily cap is **measured** from the box's own Sent folder, not configured. |
|
|
36
|
+
| `emails/steps/send.py`, `steps/reply.py` | The two pipeline steps: send one opener, answer one reply. |
|
|
37
|
+
| `emails/admin.py`, `apps.py`, `migrations/` | Django wiring and the full migration history of the `emails` app. |
|
|
38
|
+
| `core/agents/outreach.py`, `prompt.py` | The single outreach agent that owns the whole thread — cold open and every reply. Its job is **Mom Test research, not selling**: learn how the lead works today, never pitch unprompted. |
|
|
39
|
+
| `core/templates/prompts/outreach_agent.j2` | That agent's prompt. |
|
|
40
|
+
| `core/sending_window.py` | Mon–Fri, 08:00–20:00 in the *operator's* timezone, derived from their onboarding country. Openers wait for it; replies never come through it. |
|
|
41
|
+
| `tests/` | The suite for all of the above, moved unchanged. |
|
|
42
|
+
|
|
43
|
+
## What it still expects, and does not find here
|
|
44
|
+
|
|
45
|
+
Every one of these is an OpenOutreach import that did **not** come along. They are the integration
|
|
46
|
+
work, and most of them are the boundary in disguise:
|
|
47
|
+
|
|
48
|
+
| import | what it was | what it has to become |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `openoutreach.crm.models` (`Deal`, `Lead`, `DealState`) | the lead, the campaign-scoped deal, the state machine | **the port's main question.** A sequencer's unit is a recipient in a sequence, not a `Deal` in a finder's FSM. Do not recreate the finder's states here — take the exported row (`email, first_name, last_name, company, title, website, linkedin_url, reason, lead_id`) as the input and model the rest yourself. `lead_id` is the finder's join key; carry it, don't resolve it. |
|
|
51
|
+
| `openoutreach.core.db.leads` (`suppress_email`) | permanent, account-level opt-out on `Lead.disqualified` | **the opt-out duty moves with this code.** A finder that never contacts anyone is not the sender under CAN-SPAM / GDPR / CASL, so the `List-Unsubscribe` header, the `+unsub` alias, the mailbox scan and the agent's `suppress` action are all this side's obligation now. Suppression needs a home here. |
|
|
52
|
+
| `openoutreach.core.db.deals`, `db.summaries` | deal creation and conversation summaries | conversation state, so it belongs on this side; re-model against whatever replaces `Deal`. |
|
|
53
|
+
| `openoutreach.core.llm` | the pydantic-ai model factory (`SiteConfig.ai_model` + key) | needs an equivalent, or accept an injected agent. |
|
|
54
|
+
| `openoutreach.core.models.SiteConfig` | the config singleton — `country_code` drives the sending window | `sending_window.py` reads it directly; give it a seam rather than a singleton. |
|
|
55
|
+
| `openoutreach.core.conf` | `WARM_*`, `MIN_SEND_INTERVAL_SECONDS`, `SEND_WINDOW_*` | copy the constants across; `WARM_CEILING_SENDS` is **derived** from the window and the pacing, so keep the arithmetic rather than re-declaring a number. |
|
|
56
|
+
| `openoutreach.core.business_time` | Mon–Fri, used by the sending window and by thread age | small and self-contained; copy it. |
|
|
57
|
+
| `openoutreach.core.operator` | the operator `User` — the BCC target on their own campaigns | an account concept this app already has in some form. |
|
|
58
|
+
| `openoutreach.core.cycle` | the daemon loop that called these steps | **do not port.** The loop is the finder's, and it deleted its own daemon in favour of one bounded verb behind a timer. Sending here wants a cadence, not a resident process. |
|
|
59
|
+
| `openoutreach.core.logblock` | log formatting | cosmetic; drop or replace. |
|
|
60
|
+
|
|
61
|
+
## Two things not to lose
|
|
62
|
+
|
|
63
|
+
**The freemium promotional campaign does not come with this code.** OpenOutreach shipped a campaign
|
|
64
|
+
advertising itself, sent from the operator's own mailbox. It has been reclassified from growth asset
|
|
65
|
+
to install tax and dies with the sender — it should not reappear here. `LEGAL_NOTICE.md` §4 in
|
|
66
|
+
OpenOutreach is the disclosure that describes it.
|
|
67
|
+
|
|
68
|
+
**The measured send cap is the good part.** `warmth.py` reads the box's own Sent folder and derives a
|
|
69
|
+
daily ceiling from what that mailbox already does, rather than taking a configured number. It replaced
|
|
70
|
+
a declared 50/day that nobody had measured. Whatever this becomes, keep the principle: the receiver
|
|
71
|
+
counts every message the box emits, so the ledger has to count the same things.
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""`outsend` — the command on the right of the pipe.
|
|
2
|
+
|
|
3
|
+
openoutreach find 50 --json | outsend
|
|
4
|
+
|
|
5
|
+
**On the pipe it takes no verb.** Things on the right of a pipe conventionally take
|
|
6
|
+
none: `| less`, `| jq`, `| tee`. The one argument it has is the campaign, and that one
|
|
7
|
+
is usually absent too. The two verbs it does have are the ones that are not on the pipe
|
|
8
|
+
at all: `send`, which reads no stdin and mails what is already stored, and `init`,
|
|
9
|
+
which asks for what a first run needs — what the campaign sells and to whom, who is
|
|
10
|
+
signing the mail, and the mailbox it leaves from.
|
|
11
|
+
|
|
12
|
+
**Ingesting and sending are separate invocations on purpose.** A pipe's right-hand side
|
|
13
|
+
must not block on the network while a producer is still writing, and the cadence the two
|
|
14
|
+
want is different — leads arrive when `find` runs, mail moves on the mailbox's clock. So
|
|
15
|
+
the cron line is two entries, not one command doing both.
|
|
16
|
+
|
|
17
|
+
**stdout is reserved and stays clean**, so the stream composes and a receipt can
|
|
18
|
+
never corrupt it. Everything narrated — the campaign resolved, the counts, a skipped
|
|
19
|
+
line, Django's own migration chatter — goes to stderr.
|
|
20
|
+
|
|
21
|
+
**The exit code is the only acknowledgement a pipe can carry**: 0 means the rows are
|
|
22
|
+
durably persisted. Non-zero after a skipped line does *not* mean *nothing to
|
|
23
|
+
reconcile* — the good rows are stored and a re-run is safe, because ingest is
|
|
24
|
+
idempotent; it means a producer emitted something unreadable and somebody has to know.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
USAGE = """outsend [--campaign NAME] read JSON Lines on stdin, store them, exit
|
|
34
|
+
outsend send [--campaign NAME] read the mail, answer replies, open what the guards allow
|
|
35
|
+
outsend init [--campaign NAME] collect what a first run needs — the campaign, who you
|
|
36
|
+
are, and a mailbox to send from"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
"""Entry point for the `outsend` console script. Returns the exit code."""
|
|
41
|
+
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
42
|
+
_configure_logging(args.debug)
|
|
43
|
+
_boot()
|
|
44
|
+
|
|
45
|
+
from cold_outreach.errors import OutsendError
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
return {"init": _init, "send": _send}.get(args.command, _ingest)(args)
|
|
49
|
+
except OutsendError as exc:
|
|
50
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
55
|
+
parser = argparse.ArgumentParser(prog="outsend", usage=USAGE)
|
|
56
|
+
parser.add_argument("command", nargs="?", choices=["init", "send"])
|
|
57
|
+
parser.add_argument("--campaign", default=None,
|
|
58
|
+
help="which campaign these leads belong to; required only if there are several")
|
|
59
|
+
parser.add_argument("--debug", action="store_true", help="log what each step decided")
|
|
60
|
+
return parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _configure_logging(debug: bool) -> None:
|
|
64
|
+
"""Everything to stderr, because stdout belongs to the pipe."""
|
|
65
|
+
logging.basicConfig(
|
|
66
|
+
stream=sys.stderr,
|
|
67
|
+
level=logging.DEBUG if debug else logging.INFO,
|
|
68
|
+
format="%(message)s",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _boot() -> None:
|
|
73
|
+
"""Start Django and bring the store up to date.
|
|
74
|
+
|
|
75
|
+
Migrations run on every invocation and are a no-op once applied, so a fresh
|
|
76
|
+
install is an ingest that works rather than a traceback about a missing table.
|
|
77
|
+
Their narration is pointed at stderr for the same reason everything else is; a
|
|
78
|
+
migration that *fails* still raises, because that is a broken install and not an
|
|
79
|
+
answer.
|
|
80
|
+
"""
|
|
81
|
+
import django
|
|
82
|
+
|
|
83
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cold_outreach.settings")
|
|
84
|
+
django.setup()
|
|
85
|
+
|
|
86
|
+
from django.core.management import call_command
|
|
87
|
+
|
|
88
|
+
call_command("migrate", verbosity=0, stdout=sys.stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── The pipe ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _campaign_for(args: argparse.Namespace):
|
|
95
|
+
"""The campaign this invocation acts on, hydrated from the environment and narrated.
|
|
96
|
+
|
|
97
|
+
Every command starts here, so `--campaign` means the same thing to all of them and
|
|
98
|
+
the operator reads which one was chosen before anything else is printed.
|
|
99
|
+
"""
|
|
100
|
+
from cold_outreach.leads.campaigns import hydrate_from_environment, resolve_campaign
|
|
101
|
+
|
|
102
|
+
campaign = resolve_campaign(args.campaign)
|
|
103
|
+
hydrate_from_environment(campaign)
|
|
104
|
+
print(f"campaign: {campaign.name}", file=sys.stderr)
|
|
105
|
+
return campaign
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ingest(args: argparse.Namespace) -> int:
|
|
109
|
+
"""Read stdin into the resolved campaign and report what happened."""
|
|
110
|
+
from cold_outreach.leads.ingest import ingest
|
|
111
|
+
|
|
112
|
+
result = ingest(sys.stdin, _campaign_for(args))
|
|
113
|
+
print(f"stored {result.stored} lead(s)", file=sys.stderr)
|
|
114
|
+
if result.suppressed:
|
|
115
|
+
print(f"{result.suppressed} of them are suppressed and will not be emailed", file=sys.stderr)
|
|
116
|
+
if result.skipped:
|
|
117
|
+
print(f"{result.skipped} line(s) skipped — see above; the rest are stored", file=sys.stderr)
|
|
118
|
+
return 0 if result.ok else 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── The send pass ─────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _send(args: argparse.Namespace) -> int:
|
|
125
|
+
"""One bounded pass: read the mail, answer what came back, open what fits.
|
|
126
|
+
|
|
127
|
+
**`init` runs implicitly here**, because a send is the first moment the campaign's
|
|
128
|
+
fields, the operator's name and a mailbox actually have to be there — and an operator
|
|
129
|
+
who wired the pipe into a timer should not discover a setup step they never ran. On a
|
|
130
|
+
TTY that is the same prompts; headless it is the same error naming the variables,
|
|
131
|
+
raised before any mail moves.
|
|
132
|
+
|
|
133
|
+
Exit code is the pass's own: non-zero when something failed on its way out, so a
|
|
134
|
+
timer's failure mail carries it.
|
|
135
|
+
"""
|
|
136
|
+
from cold_outreach.first_run import ensure_ready
|
|
137
|
+
from cold_outreach.send_pass import run_send_pass
|
|
138
|
+
|
|
139
|
+
campaign = _campaign_for(args)
|
|
140
|
+
ensure_ready(campaign)
|
|
141
|
+
|
|
142
|
+
result = run_send_pass(campaign)
|
|
143
|
+
print(f"read {result.mirrored} new message(s) · answered {result.answered} · "
|
|
144
|
+
f"opened {result.opened}", file=sys.stderr)
|
|
145
|
+
if result.failed:
|
|
146
|
+
print(f"{result.failed} send(s) failed — see above; the next pass tries them again",
|
|
147
|
+
file=sys.stderr)
|
|
148
|
+
return 0 if result.ok else 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── First run ─────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _init(args: argparse.Namespace) -> int:
|
|
155
|
+
"""Collect the three things a send needs, and say what it ended up with.
|
|
156
|
+
|
|
157
|
+
The collecting itself lives in `first_run.py`, because `send` does exactly the same
|
|
158
|
+
thing before any mail moves and the two must not drift.
|
|
159
|
+
"""
|
|
160
|
+
from cold_outreach.core.operator import seller_full_name
|
|
161
|
+
from cold_outreach.emails.models import Mailbox
|
|
162
|
+
from cold_outreach.first_run import ensure_ready
|
|
163
|
+
|
|
164
|
+
campaign = _campaign_for(args)
|
|
165
|
+
ensure_ready(campaign)
|
|
166
|
+
print(f"campaign {campaign.name} is ready: signed by {seller_full_name()}, "
|
|
167
|
+
f"sending from {Mailbox.objects.first()}", file=sys.stderr)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
raise SystemExit(main())
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# cold_outreach/core/agents/outreach.py
|
|
2
|
+
"""The outreach agent: one prompt, one decision type, both ends of the thread.
|
|
3
|
+
|
|
4
|
+
There is a single conversational agent. The cold open and every later reply are
|
|
5
|
+
the same voice doing the same job — Mom Test research, not selling — so they
|
|
6
|
+
render from one template (``outreach_agent.j2``) which branches on the only thing
|
|
7
|
+
that actually differs: whether a thread exists yet.
|
|
8
|
+
|
|
9
|
+
- **first touch** (no thread): the agent must ``send_message`` and must supply a
|
|
10
|
+
``subject``. ``emails/steps/send.py`` sends it and records the thread root.
|
|
11
|
+
- **in thread**: the agent only ever runs on a thread the lead has *replied* to —
|
|
12
|
+
the mail pass has already written the reply — so it picks ``send_message`` /
|
|
13
|
+
``mark_completed`` / ``suppress``. ``emails/steps/reply.py`` executes the choice.
|
|
14
|
+
|
|
15
|
+
There is no ``wait`` and no follow-up interval, because nobody is chased: silence
|
|
16
|
+
is not a decision the agent gets to make, it is simply the absence of work.
|
|
17
|
+
|
|
18
|
+
``suppress`` is the *worded* unsubscribe — "take me off your list", "stop
|
|
19
|
+
emailing me". It threads like any other reply, so the box-wide alias scan in
|
|
20
|
+
``emails/classify.py`` can never see it, and the agent reading every reply already
|
|
21
|
+
can. It is a stronger statement than ``not_interested``: it ends the thread and
|
|
22
|
+
suppresses the person account-wide, across every campaign.
|
|
23
|
+
|
|
24
|
+
Single LLM call with structured output — no tool-calling loop.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from datetime import datetime, timedelta
|
|
30
|
+
from typing import Literal
|
|
31
|
+
|
|
32
|
+
from django.utils import timezone
|
|
33
|
+
from pydantic import BaseModel, Field, model_validator
|
|
34
|
+
from pydantic_ai import Agent
|
|
35
|
+
|
|
36
|
+
from cold_outreach.core.agents.prompt import _format_facts, base_context, render
|
|
37
|
+
from cold_outreach.core.business_time import business_days_between
|
|
38
|
+
from cold_outreach.core.llm import get_llm_model, run_agent_sync
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutreachDecision(BaseModel):
|
|
44
|
+
"""Structured output from the outreach agent, at either end of the thread."""
|
|
45
|
+
|
|
46
|
+
action: Literal["send_message", "mark_completed", "suppress"] = Field(
|
|
47
|
+
description=(
|
|
48
|
+
"What to do next for this lead. The first email in a thread is always send_message. "
|
|
49
|
+
"Use suppress when the lead asked to stop being contacted."
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
subject: str | None = Field(
|
|
53
|
+
default=None,
|
|
54
|
+
description=(
|
|
55
|
+
"Subject line. Required on the first email of a thread — short and specific, "
|
|
56
|
+
"like a real person wrote it, not salesy. Omit when replying in an existing thread."
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
message: str | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description="The email to send. Required when action='send_message'. A few short sentences, no signature.",
|
|
62
|
+
)
|
|
63
|
+
outcome: Literal[
|
|
64
|
+
"converted", "not_interested", "wrong_fit", "no_budget",
|
|
65
|
+
"has_solution", "bad_timing", "unresponsive",
|
|
66
|
+
] | None = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
description="Why the conversation ended. Required when action='mark_completed'.",
|
|
69
|
+
)
|
|
70
|
+
@model_validator(mode="after")
|
|
71
|
+
def _check_required_fields(self):
|
|
72
|
+
if self.action == "send_message" and not self.message:
|
|
73
|
+
raise ValueError("message is required when action='send_message'")
|
|
74
|
+
if self.action == "mark_completed" and not self.outcome:
|
|
75
|
+
raise ValueError("outcome is required when action='mark_completed'")
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Number of trailing verbatim messages the agent sees alongside the rolling
|
|
80
|
+
# chat_summary. Older turns live in the summary fact list; the recency window
|
|
81
|
+
# preserves literal phrasing for the turns that matter most when composing
|
|
82
|
+
# the next reply.
|
|
83
|
+
RECENT_MESSAGES_WINDOW = 6
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_outreach_agent(deal) -> OutreachDecision:
|
|
87
|
+
"""Decide the next move for ``deal`` — the cold open or the answer to a reply.
|
|
88
|
+
|
|
89
|
+
The caller has already folded any new inbound messages into ``deal.chat_summary``
|
|
90
|
+
(``emails/steps/reply.py``), so this reads the conversation store rather than the
|
|
91
|
+
mailbox — no IMAP here. On a first touch there is nothing to read, and the
|
|
92
|
+
decision is validated to be a sendable opener with a subject.
|
|
93
|
+
"""
|
|
94
|
+
public_id = deal.lead.public_id
|
|
95
|
+
is_first_touch = not deal.thread_id
|
|
96
|
+
|
|
97
|
+
recent = [] if is_first_touch else _load_recent_messages(deal)
|
|
98
|
+
system_prompt = _render_system_prompt(deal, recent, is_first_touch)
|
|
99
|
+
|
|
100
|
+
agent = Agent(
|
|
101
|
+
get_llm_model(),
|
|
102
|
+
output_type=OutreachDecision,
|
|
103
|
+
model_settings={"temperature": 0.7, "timeout": 60},
|
|
104
|
+
)
|
|
105
|
+
decision = run_agent_sync(agent.run(system_prompt)).output
|
|
106
|
+
if decision is None:
|
|
107
|
+
raise RuntimeError(f"LLM returned unparseable response for outreach to {public_id}")
|
|
108
|
+
if is_first_touch:
|
|
109
|
+
_validate_opener(decision, public_id)
|
|
110
|
+
|
|
111
|
+
logger.info("outreach agent for %s: %s", public_id, decision.action)
|
|
112
|
+
return decision
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validate_opener(decision: OutreachDecision, public_id: str) -> None:
|
|
116
|
+
"""A first touch must be a sendable email with its own subject."""
|
|
117
|
+
if decision.action != "send_message":
|
|
118
|
+
raise ValueError(f"opener for {public_id} must send_message, got {decision.action}")
|
|
119
|
+
if not decision.subject:
|
|
120
|
+
raise ValueError(f"opener for {public_id} has no subject")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Prompt context ────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _render_system_prompt(deal, recent_messages: list, is_first_touch: bool) -> str:
|
|
127
|
+
"""Render the outreach prompt for whichever end of the thread we're at."""
|
|
128
|
+
now = timezone.now()
|
|
129
|
+
thread_context = {} if is_first_touch else {
|
|
130
|
+
"chat_summary": _format_facts(deal.chat_summary),
|
|
131
|
+
"recent_messages": _format_recent_messages(recent_messages, now),
|
|
132
|
+
"today": now.strftime("%Y-%m-%d"),
|
|
133
|
+
"business_days_since_last_outgoing": _business_days_since_last_outgoing(recent_messages, now),
|
|
134
|
+
}
|
|
135
|
+
return render(
|
|
136
|
+
"outreach_agent.j2",
|
|
137
|
+
**base_context(deal),
|
|
138
|
+
is_first_touch=is_first_touch,
|
|
139
|
+
**thread_context,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load_recent_messages(deal, limit: int = RECENT_MESSAGES_WINDOW) -> list:
|
|
144
|
+
"""The thread's last `limit` **turns**, in chronological order.
|
|
145
|
+
|
|
146
|
+
The recency window of verbatim turns the agent sees alongside the rolling
|
|
147
|
+
``chat_summary`` — the opener plus any replies read from the mailbox. Turns
|
|
148
|
+
only, so a non-delivery report cannot reach the agent's reasoning and be
|
|
149
|
+
answered as though a person had written it.
|
|
150
|
+
"""
|
|
151
|
+
if not deal.thread_id:
|
|
152
|
+
return []
|
|
153
|
+
return list(reversed(list(deal.thread.turns().order_by("-sent_at", "-pk")[:limit])))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _format_recent_messages(messages: list, now: datetime) -> str:
|
|
157
|
+
"""Render the last few turns as a timestamped transcript."""
|
|
158
|
+
if not messages:
|
|
159
|
+
return "No recent messages."
|
|
160
|
+
lines = []
|
|
161
|
+
for m in messages:
|
|
162
|
+
content = (m.body_text or "").strip()
|
|
163
|
+
if not content:
|
|
164
|
+
continue
|
|
165
|
+
speaker = "Me" if m.is_outbound else "Lead"
|
|
166
|
+
prefix = f"{speaker} ({_humanize_age(m.sent_at, now)})" if m.sent_at else speaker
|
|
167
|
+
lines.append(f"{prefix}: {content}")
|
|
168
|
+
return "\n".join(lines) or "No recent messages."
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _humanize_age(when: datetime, now: datetime) -> str:
|
|
172
|
+
"""Render `when` as a coarse age relative to `now` (e.g. ``3d ago``)."""
|
|
173
|
+
delta = now - when
|
|
174
|
+
if delta < timedelta(hours=1):
|
|
175
|
+
return f"{max(int(delta.total_seconds() // 60), 1)}m ago"
|
|
176
|
+
if delta < timedelta(days=1):
|
|
177
|
+
return f"{int(delta.total_seconds() // 3600)}h ago"
|
|
178
|
+
return f"{delta.days}d ago"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _business_days_since_last_outgoing(messages: list, now: datetime) -> int | None:
|
|
182
|
+
"""Whole working days since the most recent outgoing message, or None if there are none.
|
|
183
|
+
|
|
184
|
+
Weekends don't count — a Friday send read on Monday is one working day old,
|
|
185
|
+
which is the gap the agent should pace against.
|
|
186
|
+
"""
|
|
187
|
+
timestamps = [m.sent_at for m in messages if m.is_outbound and m.sent_at]
|
|
188
|
+
if not timestamps:
|
|
189
|
+
return None
|
|
190
|
+
return business_days_between(max(timestamps), now)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# cold_outreach/core/agents/prompt.py
|
|
2
|
+
"""Jinja plumbing + the shared context for the outreach prompt.
|
|
3
|
+
|
|
4
|
+
One template renders both the cold open and every in-thread reply
|
|
5
|
+
(``outreach_agent.j2``); ``base_context`` is the half of its variables that
|
|
6
|
+
doesn't depend on where in the thread we are. ``core.agents.outreach`` adds the
|
|
7
|
+
conversation half.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import jinja2
|
|
12
|
+
|
|
13
|
+
from cold_outreach.core.conf import PROMPTS_DIR
|
|
14
|
+
from cold_outreach.core.operator import seller_full_name
|
|
15
|
+
from cold_outreach.leads.summaries import facts_of
|
|
16
|
+
|
|
17
|
+
_ENV = jinja2.Environment(loader=jinja2.FileSystemLoader(str(PROMPTS_DIR)))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render(template_name: str, **context) -> str:
|
|
21
|
+
"""Render a prompt template by name from the shared prompts dir."""
|
|
22
|
+
return _ENV.get_template(template_name).render(**context)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def base_context(deal) -> dict:
|
|
26
|
+
"""The channel-agnostic prompt variables shared by every outreach entrypoint.
|
|
27
|
+
|
|
28
|
+
Four of the five come from the campaign and the operator. The fifth is what we
|
|
29
|
+
know about the person: the facts extracted from the raw ``profile_text`` the pipe
|
|
30
|
+
carried (``leads/summaries.py``), falling back to that text itself if the
|
|
31
|
+
extraction has not run — the same information either way, and a lead is never
|
|
32
|
+
described to the agent as *(none yet)* while the sentences are sitting in the row.
|
|
33
|
+
"""
|
|
34
|
+
campaign = deal.campaign
|
|
35
|
+
return {
|
|
36
|
+
"self_name": seller_full_name(),
|
|
37
|
+
"product_docs": campaign.product_docs or "",
|
|
38
|
+
"campaign_target": campaign.campaign_target or "",
|
|
39
|
+
"booking_link": campaign.booking_link or "",
|
|
40
|
+
"profile_summary": _format_profile(deal.lead),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _format_profile(lead) -> str:
|
|
45
|
+
"""The lead's facts as bullets, or the raw profile text, or an admission of nothing."""
|
|
46
|
+
facts = facts_of(lead.profile_summary)
|
|
47
|
+
if facts:
|
|
48
|
+
return "\n".join(f"- {fact}" for fact in facts)
|
|
49
|
+
return lead.profile_text or "(nothing on file)"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_facts(summary: dict | None) -> str:
|
|
53
|
+
"""Render a ``{"facts": [...]}`` blob as a bullet list."""
|
|
54
|
+
facts = facts_of(summary)
|
|
55
|
+
if not facts:
|
|
56
|
+
return "(none yet)"
|
|
57
|
+
return "\n".join(f"- {fact}" for fact in facts)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# cold_outreach/core/business_time.py
|
|
2
|
+
"""Working-day arithmetic — how old a thread really is.
|
|
3
|
+
|
|
4
|
+
A thread that sat untouched over a weekend has not really gone quiet, and the
|
|
5
|
+
outreach agent is told the gap in working days so it reads a Friday reply answered
|
|
6
|
+
on Monday as one day old rather than three.
|
|
7
|
+
|
|
8
|
+
Measuring is all this does. Public holidays are deliberately not modelled: they are
|
|
9
|
+
per-country, per-year data we do not carry, and this figure is coarse enough that a
|
|
10
|
+
missed holiday costs one slightly-off number in a prompt.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import date, datetime, timedelta
|
|
15
|
+
|
|
16
|
+
SATURDAY = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_business_day(day: date) -> bool:
|
|
20
|
+
"""True for Monday–Friday."""
|
|
21
|
+
return day.weekday() < SATURDAY
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def business_days_between(start: datetime, end: datetime) -> int:
|
|
25
|
+
"""Whole working days elapsed from ``start`` to ``end`` (weekends excluded).
|
|
26
|
+
|
|
27
|
+
Counts the business days in the half-open range ``(start.date(), end.date()]``,
|
|
28
|
+
so Friday → Monday is 1 and Friday → Sunday is 0. Never negative.
|
|
29
|
+
"""
|
|
30
|
+
return _business_days_in_range(start.date() + timedelta(days=1),
|
|
31
|
+
end.date() + timedelta(days=1))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Internals ─────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _business_days_in_range(start: date, end: date) -> int:
|
|
38
|
+
"""Number of Monday–Friday days in the half-open range ``[start, end)``."""
|
|
39
|
+
days = (end - start).days
|
|
40
|
+
if days <= 0:
|
|
41
|
+
return 0
|
|
42
|
+
full_weeks, remainder = divmod(days, 7)
|
|
43
|
+
count = full_weeks * 5
|
|
44
|
+
for offset in range(remainder):
|
|
45
|
+
if is_business_day(start + timedelta(days=offset)):
|
|
46
|
+
count += 1
|
|
47
|
+
return count
|