Files
Chris Olson 9131e83c08 Improve signal quality (Exa news category) and remove mock fallback
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>
2026-06-24 10:41:41 -04:00

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.
```