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.
Files changed (63) hide show
  1. cold_outreach/README.md +71 -0
  2. cold_outreach/__init__.py +0 -0
  3. cold_outreach/__main__.py +172 -0
  4. cold_outreach/core/__init__.py +0 -0
  5. cold_outreach/core/agents/__init__.py +0 -0
  6. cold_outreach/core/agents/outreach.py +190 -0
  7. cold_outreach/core/agents/prompt.py +57 -0
  8. cold_outreach/core/business_time.py +47 -0
  9. cold_outreach/core/conf.py +135 -0
  10. cold_outreach/core/llm.py +235 -0
  11. cold_outreach/core/operator.py +68 -0
  12. cold_outreach/core/sending_window.py +63 -0
  13. cold_outreach/core/templates/prompts/outreach_agent.j2 +98 -0
  14. cold_outreach/docs/outreach_agent.md +59 -0
  15. cold_outreach/docs/template-variables.md +18 -0
  16. cold_outreach/docs/templating.md +12 -0
  17. cold_outreach/emails/__init__.py +0 -0
  18. cold_outreach/emails/admin.py +63 -0
  19. cold_outreach/emails/apps.py +8 -0
  20. cold_outreach/emails/classify.py +192 -0
  21. cold_outreach/emails/delivery_policy.py +250 -0
  22. cold_outreach/emails/mail_pass.py +33 -0
  23. cold_outreach/emails/migrations/0001_initial.py +37 -0
  24. cold_outreach/emails/migrations/0002_mailbox_signature.py +23 -0
  25. cold_outreach/emails/migrations/0003_alter_mailbox_daily_limit.py +36 -0
  26. cold_outreach/emails/migrations/0004_alter_mailbox_daily_limit_sendverdict.py +66 -0
  27. cold_outreach/emails/migrations/0005_mailbox_unsub_scan_uid_and_more.py +23 -0
  28. cold_outreach/emails/migrations/0006_mailbox_next_send_at.py +18 -0
  29. cold_outreach/emails/migrations/0007_deliveryevent_foldercoverage_message_thread_and_more.py +283 -0
  30. cold_outreach/emails/migrations/__init__.py +0 -0
  31. cold_outreach/emails/models/__init__.py +30 -0
  32. cold_outreach/emails/models/mailbox.py +223 -0
  33. cold_outreach/emails/models/maillog.py +263 -0
  34. cold_outreach/emails/parsing.py +178 -0
  35. cold_outreach/emails/project.py +148 -0
  36. cold_outreach/emails/report.py +39 -0
  37. cold_outreach/emails/sender.py +296 -0
  38. cold_outreach/emails/smtp.py +47 -0
  39. cold_outreach/emails/steps/__init__.py +0 -0
  40. cold_outreach/emails/steps/reply.py +162 -0
  41. cold_outreach/emails/steps/send.py +92 -0
  42. cold_outreach/emails/sync.py +267 -0
  43. cold_outreach/emails/threads.py +92 -0
  44. cold_outreach/emails/warmth.py +255 -0
  45. cold_outreach/errors.py +15 -0
  46. cold_outreach/first_run.py +239 -0
  47. cold_outreach/leads/__init__.py +11 -0
  48. cold_outreach/leads/apps.py +7 -0
  49. cold_outreach/leads/campaigns.py +87 -0
  50. cold_outreach/leads/ingest.py +194 -0
  51. cold_outreach/leads/migrations/0001_initial.py +134 -0
  52. cold_outreach/leads/migrations/__init__.py +0 -0
  53. cold_outreach/leads/models.py +227 -0
  54. cold_outreach/leads/pools.py +76 -0
  55. cold_outreach/leads/summaries.py +175 -0
  56. cold_outreach/leads/suppression.py +67 -0
  57. cold_outreach/send_pass.py +148 -0
  58. cold_outreach/settings.py +74 -0
  59. openoutsend-0.1.16.dist-info/METADATA +163 -0
  60. openoutsend-0.1.16.dist-info/RECORD +63 -0
  61. openoutsend-0.1.16.dist-info/WHEEL +4 -0
  62. openoutsend-0.1.16.dist-info/entry_points.txt +2 -0
  63. openoutsend-0.1.16.dist-info/licenses/LICENSE +9 -0
@@ -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