Exa harvester: - EXA_CATEGORY=news (default) restricts to news articles, excluding evergreen careers/marketing landing pages that were producing irrelevant signals. - Event-focused query (acquisition/earnings/launch/leadership), dropped hiring/career terms that pulled recruiting pages. - Domain scoping now OFF by default (broad-web news about the account beats first-party careers pages); add EXA_EXCLUDE_DOMAINS and loop --domain-filter. LLM layer: - Remove the mock reasoner entirely (no provider => loop refuses to run). - On HTTP 429, WAIT (LLM_RATELIMIT_WAIT_SECONDS, default 900s; honors Retry-After) and retry up to LLM_RATELIMIT_MAX_RETRIES (24) instead of degrading to mock output. - If the LLM is ultimately unavailable for an account, skip it and leave its signals unconsumed (retried next run) — never fabricate briefs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
8.6 KiB
Markdown
184 lines
8.6 KiB
Markdown
# Sales Prospecting Agent Loop — MVP (v1)
|
|
|
|
A runnable first cut of the closed-loop account-intelligence system described in
|
|
[`../sales-prospecting-agent-loop.md`](../sales-prospecting-agent-loop.md). It
|
|
harvests signals per account, evaluates them against a capability library, scores
|
|
and synthesizes opportunity briefs, delivers them, and feeds rep feedback back into
|
|
scoring — the full loop, end to end.
|
|
|
|
## ⚠️ What's real vs. placeholder in this version
|
|
|
|
This MVP is built to **run today with zero setup** so we can review output quality
|
|
and iterate. Deliberate v1 simplifications (we'll revisit each tomorrow):
|
|
|
|
| Design calls for | v1 MVP uses | Why |
|
|
|---|---|---|
|
|
| TypeScript / Node + BullMQ | **Python 3, stdlib only** | Node isn't installed on this machine; concepts map 1:1. Easy to port. |
|
|
| Proxycurl/Apify, JSearch, SEC EDGAR | **Fixture file** (`data/fixtures.json`) | No API keys needed; swap each connector for the real API. |
|
|
| Exa.ai news/web search | **LIVE** when `EXA_API_KEY` set (else fixtures) | Real connector with hard request ceilings — see below. |
|
|
| Salesforce/HubSpot | **`data/accounts.json`** + console write-back | Read context + log-activity stub. |
|
|
| Postgres + Redis + pgvector | **JSON files** under `output/state/` | Hash dedup works; semantic dedup is a documented v2 hook. |
|
|
| Vector ANN capability pre-filter | **Keyword-overlap pre-filter** | Same interface, no embedding service. |
|
|
| Slack Bolt + SendGrid | **Console + markdown files** | Briefs render Slack-style; digest writes to `output/digest.md`. |
|
|
| LLM relevance + synthesis | **OpenAI-compatible / Gemini / Anthropic** (required) | Provider-agnostic (`LLM_PROVIDER`). **No mock fallback** — on a rate limit it waits and retries; without a key it refuses to run. |
|
|
|
|
## Run it
|
|
|
|
```bash
|
|
cd sales-agent
|
|
|
|
# 1. Run the loop over all target accounts (harvest -> deliver)
|
|
python3 run.py loop
|
|
|
|
# Single account or one tier:
|
|
python3 run.py loop --account acct_meridian
|
|
python3 run.py loop --tier strategic
|
|
|
|
# 2. See the per-rep daily digest
|
|
python3 run.py digest
|
|
python3 run.py digest --rep rep_sarah
|
|
|
|
# 3. Record rep feedback (closes the loop)
|
|
python3 run.py feedback --brief <BRIEF_ID> --action act_on
|
|
# actions: act_on | snooze | reject | won | lost (reject also suppresses 14d)
|
|
|
|
# 4. Recompute scoring weights from feedback (the weekly recalibration job)
|
|
python3 run.py recalibrate
|
|
|
|
# Helpers
|
|
python3 run.py accounts # list target accounts
|
|
python3 run.py briefs # list all generated briefs + status
|
|
```
|
|
|
|
### LLM reasoning (provider-agnostic)
|
|
|
|
The relevance filter and synthesizer call whichever provider is configured. Set in
|
|
`.env`:
|
|
|
|
```bash
|
|
# OpenAI-compatible (default) — OpenAI, OpenRouter, Together, Groq, vLLM, Ollama, ...
|
|
LLM_PROVIDER=openai
|
|
OPENAI_BASE_URL=https://api.openai.com/v1 # any /chat/completions endpoint
|
|
OPENAI_API_KEY=...
|
|
OPENAI_MODEL=gpt-4o-mini
|
|
OPENAI_JSON_MODE=1 # set 0 if the endpoint rejects json_object
|
|
|
|
# or Gemini
|
|
LLM_PROVIDER=gemini
|
|
GEMINI_API_KEY=...
|
|
GEMINI_MODEL=gemini-2.5-flash
|
|
|
|
# or Anthropic
|
|
LLM_PROVIDER=anthropic
|
|
ANTHROPIC_API_KEY=...
|
|
LLM_MODEL=claude-sonnet-4-6
|
|
```
|
|
|
|
If `LLM_PROVIDER` is blank it auto-detects from whichever key is present (OpenAI-
|
|
compatible preferred). All providers use plain `urllib` — no SDKs. LLM calls are
|
|
batched (one relevance call + one synthesis call per account).
|
|
|
|
**No mock fallback.** Quality is the priority: the loop refuses to run without a
|
|
configured provider, and on HTTP 429 it **waits and retries** (`LLM_RATELIMIT_WAIT_
|
|
SECONDS`, default 900s; honors `Retry-After`) up to `LLM_RATELIMIT_MAX_RETRIES`
|
|
(default 24) rather than emitting low-quality output. If the LLM is ultimately
|
|
unavailable for an account, that account is skipped and its signals are left
|
|
unconsumed (retried next run) — never fabricated. Degenerate/unparseable responses
|
|
(e.g. a model repetition loop) are re-sampled `LLM_CONTENT_RETRIES` times.
|
|
|
|
## Exa.ai news connector & free-plan protection
|
|
|
|
The news harvester ([`src/connectors/exa.py`](src/connectors/exa.py)) calls the real
|
|
Exa search API (`POST https://api.exa.ai/search`) when `EXA_API_KEY` is set, and
|
|
falls back to fixtures otherwise. **Two hard ceilings keep you inside the free plan**
|
|
(Exa's free tier is ~20,000 requests/month; we default far below that):
|
|
|
|
- `EXA_MONTHLY_REQUEST_CAP` (default **1000**) — a persistent counter in
|
|
`output/state/exa_usage.json` that resets at the start of each calendar month.
|
|
Once hit, the connector refuses to call Exa and falls back to fixtures.
|
|
- `EXA_PER_RUN_REQUEST_CAP` (default **25**) — a per-process cap so a single loop
|
|
can't burn through the budget.
|
|
|
|
A request is only counted when a call actually reaches Exa (a billable call);
|
|
network failures that never hit Exa are not counted. Each account uses **one** Exa
|
|
request per loop run (so the default 3 sample accounts = 3 requests/run).
|
|
|
|
```bash
|
|
python3 run.py exa-usage # show remaining monthly budget
|
|
```
|
|
|
|
### Signal quality: news category & domain scoping
|
|
|
|
The single biggest lever on signal quality is **`EXA_CATEGORY=news`** (default): it
|
|
restricts results to news articles and keeps out evergreen pages (careers, marketing,
|
|
generic corporate). The query is also event-focused (acquisition, earnings, product
|
|
launch, leadership change, ...) rather than pulling recruiting pages.
|
|
|
|
Domain scoping is **off by default** — with `category=news`, broad-web search finds
|
|
actual coverage *about* the account (PRNewswire, CNBC, trade press). Scope to the
|
|
company's own newsroom only if you want first-party press releases:
|
|
|
|
```bash
|
|
python3 run.py loop --domain-filter # account's own domain (newsroom/press only)
|
|
python3 run.py loop --no-domain-filter # broad-web news about the account (default)
|
|
# or set EXA_DOMAIN_FILTER / EXA_CATEGORY / EXA_EXCLUDE_DOMAINS in .env
|
|
```
|
|
|
|
Tune all limits in `.env` (`EXA_MONTHLY_REQUEST_CAP`, `EXA_PER_RUN_REQUEST_CAP`,
|
|
`EXA_RESULTS_PER_QUERY`, `EXA_LOOKBACK_DAYS`, `EXA_CATEGORY`). Set `EXA_ENABLED=0` to
|
|
force fixtures.
|
|
|
|
## How the loop maps to the design
|
|
|
|
```
|
|
run.py loop
|
|
└─ src/pipeline.run_account_cycle (design §7.3)
|
|
1. agents/harvester Phase 1 signal harvest (connectors/*)
|
|
2. agents/normalizer Phase 2 normalize + hash dedup
|
|
3. agents/relevance Phase 3 LLM vs. capability library
|
|
4. agents/synthesizer Phase 4 opportunity briefs
|
|
5. agents/scorer Phase 5 composite score + priority
|
|
6. delivery/render Phase 7 deliver (console/md) + CRM log
|
|
7. memory/store Phase 6 account memory + suppression
|
|
feedback ─ memory/feedback §6 feedback store + recalibration
|
|
```
|
|
|
|
## Project layout
|
|
|
|
```
|
|
sales-agent/
|
|
├── run.py CLI entrypoint
|
|
├── data/
|
|
│ ├── accounts.json target accounts (stand-in for CRM)
|
|
│ ├── capabilities.json the Capability Library (the "brain")
|
|
│ └── fixtures.json placeholder harvested signals
|
|
├── src/
|
|
│ ├── config.py paths, thresholds, .env loader
|
|
│ ├── types.py RawSignal / NormalizedSignal / Brief dataclasses
|
|
│ ├── llm.py Anthropic call (urllib) + mock fallback
|
|
│ ├── capabilities.py library loader + keyword pre-filter
|
|
│ ├── connectors/ linkedin, news, jobs, crm (fixture-backed)
|
|
│ ├── agents/ harvester, normalizer, relevance, synthesizer, scorer
|
|
│ ├── memory/ store (dedup/memory/briefs) + feedback (recalibration)
|
|
│ ├── delivery/ render (briefs) + digest
|
|
│ └── pipeline.py the per-account orchestration
|
|
└── output/ generated briefs, digest, and JSON state (created on run)
|
|
```
|
|
|
|
## Suggested review focus for tomorrow
|
|
|
|
1. **Capability Library** (`data/capabilities.json`) — now seeded with 40 real
|
|
Broadcom/VMware capabilities across Mainframe (13), Enterprise Software (9),
|
|
Identity & Access Management (6), and VMware by Broadcom (12). Review wording of
|
|
buying-signals/anti-signals and add any missing SKUs. The prior placeholder library
|
|
is kept at `data/capabilities.placeholder.json`.
|
|
2. **Accounts** (`data/accounts.json`) — sample BSG target accounts; swap for a real
|
|
target-account list + CRM pull.
|
|
3. **Scoring weights** — the composite formula in `agents/scorer.py` uses the design's
|
|
weights; tune the tier/seniority/timing tables.
|
|
4. **Connectors** — Exa.ai news is now **live** (with request caps). Improve query
|
|
precision (a bare company name pulls in stock-ticker noise — add domain filtering
|
|
via the account `domain`), then wire the next source (jobs/LinkedIn).
|
|
5. **Reasoning quality** — set an API key and compare Claude briefs vs. the mock.
|
|
```
|