diff --git a/sales-agent/.env.example b/sales-agent/.env.example index 71db156..cea9237 100644 --- a/sales-agent/.env.example +++ b/sales-agent/.env.example @@ -1,9 +1,17 @@ # Copy to .env and fill in. The loop runs WITHOUT a key using a mock reasoner. # --- LLM provider --- -# LLM_PROVIDER: openai | gemini | anthropic (auto-detected from whichever key is -# set if blank; OpenAI-compatible preferred). Provider w/ a missing key => mock. -LLM_PROVIDER=openai +# LLM_PROVIDER: anthropic | openai | gemini (auto-detected if blank). +LLM_PROVIDER=anthropic + +# Anthropic via OAuth (Claude subscription) — token from ANTHROPIC_OAUTH_TOKEN, +# else the Claude Code credential in the macOS keychain (auto-refreshed by +# Claude Code; run any `claude -p ...` command if it has expired). +LLM_MODEL=claude-sonnet-5 +ANTHROPIC_EFFORT=low # low | medium | high | max +ANTHROPIC_USE_KEYCHAIN=1 +# ANTHROPIC_OAUTH_TOKEN= +# ANTHROPIC_API_KEY= # OpenAI-compatible endpoint — works with OpenAI, OpenRouter, Together, Groq, # vLLM, Ollama, LM Studio, etc. Just point these three at any /chat/completions API: @@ -20,8 +28,10 @@ LLM_RATELIMIT_MAX_RETRIES=24 # Other providers: GEMINI_API_KEY= GEMINI_MODEL=gemini-2.5-flash -ANTHROPIC_API_KEY= -LLM_MODEL=claude-sonnet-4-6 + +# --- SEC EDGAR filings harvester (free, no key; public accounts auto-matched) --- +SEC_ENABLED=1 +SEC_MAX_FILINGS_PER_ACCOUNT=5 # Scoring thresholds (design Phase 5) PRIORITY_THRESHOLD=0.72 diff --git a/sales-agent/.gitignore b/sales-agent/.gitignore index 42251b5..9045a03 100644 --- a/sales-agent/.gitignore +++ b/sales-agent/.gitignore @@ -2,3 +2,8 @@ output/ .env __pycache__/ *.pyc + +# Confidential entitlement data — never commit +data/SE_ACL*.csv +data/*ACL*.csv +data/entitlements.json diff --git a/sales-agent/USAGE.md b/sales-agent/USAGE.md new file mode 100644 index 0000000..93c5feb --- /dev/null +++ b/sales-agent/USAGE.md @@ -0,0 +1,235 @@ +# Usage Reference — Sales Prospecting Agent Loop + +All commands run from `sales-agent/`: `python3 run.py [flags]` + +--- + +## Commands + +### `loop` — run the harvest → reason → deliver cycle + +```bash +python3 run.py loop # all accounts (ordered, see below) +python3 run.py loop --limit 30 # at most 30 accounts this run +python3 run.py loop --account 300000172847316 +python3 run.py loop --tier strategic # strategic | enterprise | growth +python3 run.py loop --rep "Robert Parker" # one rep's accounts (name or id substring) +python3 run.py loop --tier strategic --limit 10 +python3 run.py loop --domain-filter # Exa: account's own domain only +python3 run.py loop --no-domain-filter # Exa: broad-web news (default) +``` + +| Flag | Effect | +|---|---| +| `--account ID` | Run a single account by `accountId` (OEC number) | +| `--tier T` | Only `strategic` / `enterprise` / `growth` accounts | +| `--rep NAME` | Only accounts assigned to a rep (case-insensitive name or `rep_id` substring) | +| `--limit N` | Process at most N accounts. Order: **strategic tier first, then least-recently-scanned** (never-scanned first). The run cursor (`output/state/run_cursor.json`) persists, so successive `--limit` runs walk the whole territory — e.g. a nightly `loop --limit 30` covers 342 accounts in ~12 days. | +| `--domain-filter` | Scope Exa news to the account's own domain (newsroom/PR only) | +| `--no-domain-filter` | Broad-web news about the account (default) | + +Per-account flow: harvest (Exa news + SEC EDGAR filings + stubs) → dedup → +**quality gate** (drops awards/CSR/ticker chatter pre-LLM; log: +`output/state/quality_gate_log.json`) → LLM relevance filter (with rolling +**account narrative** as context) → LLM synthesis (briefs with **cited talking +points** `[S#]` + updated narrative) → **citation verification** (uncited +points dropped; brief skipped if none survive) → scoring → delivery +(`output/briefs/*.md`) → memory + cursor update. + +On LLM rate-limit (429): waits and retries (no fabricated output). On LLM +failure: account skipped, its signals retried next run. + +### `digest` — per-rep intelligence digest + +```bash +python3 run.py digest # text digest, all reps (also -> output/digest.md) +python3 run.py digest --rep rep_robert_parker # filter by rep id +python3 run.py digest --rep "Misty Brew" --html # forwardable HTML -> output/digests/ +python3 run.py digest --html # HTML for all reps +``` + +| Flag | Effect | +|---|---| +| `--rep X` | Filter to one rep (text mode: rep id; HTML mode: name substring or id) | +| `--html` | Write a self-contained HTML file (`output/digests/digest__.html`) — clean enough to paste/forward as an email body | + +### `renewals` — upcoming renewals by month (local entitlement data) + +```bash +python3 run.py renewals # next 6 months +python3 run.py renewals --months 12 --limit 30 +``` + +| Flag | Effect | +|---|---| +| `--months N` | Horizon (default 6) | +| `--limit N` | Max rows per month (default 15) | + +Renewal proximity also feeds scoring automatically: a brief whose capability +(or account) has a renewal within `RENEWAL_WINDOW_DAYS` (default 365) gets a +boost up to `RENEWAL_BOOST_MAX` (default +0.10; half for account-level +renewals), scaled by closeness — computed locally, no LLM. Boosted briefs show +a **💰 RENEWAL WINDOW** block in the brief/digest. + +### `whitespace` — territory coverage report + +```bash +python3 run.py whitespace # prints + writes output/whitespace.md +``` + +Shows: scanned vs never-scanned vs stale (>14d) accounts, opportunities by +capability, pipeline status counts, per-account coverage, and the backlog of +never-scanned accounts. (Becomes a true owns-vs-whitespace matrix once +entitlement data populates `currentProducts`.) + +### `feedback` — record a rep's verdict on a brief + +```bash +python3 run.py feedback --brief --action act_on +python3 run.py feedback --brief --action reject --note "already own this" +``` + +| Flag | Effect | +|---|---| +| `--brief ID` | Brief id (see `briefs` command or the digest) | +| `--action A` | `act_on` \| `snooze` \| `reject` \| `won` \| `lost` | +| `--note "..."` | Optional free-text context, stored with the feedback | + +### `recalibrate` — recompute feedback-driven scoring weights + +```bash +python3 run.py recalibrate # weekly job; needs >=3 samples per combo +``` + +### `accounts` / `briefs` / `exa-usage` — inspection + +```bash +python3 run.py accounts # list all target accounts (tier, rep, products) +python3 run.py briefs # all briefs, ranked by score, with status +python3 run.py exa-usage # Exa request budget (monthly cap / remaining) +``` + +### Account import (repeatable) + +```bash +python3 scripts/import_accounts.py "/path/to/ISG Customer Sales Assignments.csv" +``` + +Rebuilds `data/accounts.json` from a territory CSV export (see script header +for the field mapping). Backs up nothing — copy `data/accounts.json` first. + +### Entitlement import (repeatable, 100% local) + +```bash +python3 scripts/import_entitlements.py data/SE_ACL_FY26_Q3.csv +``` + +Loads the confidential ACL export **entirely locally** (no LLM, no API): +aggregates active contract lines per parent, joins to accounts by normalized +parent name (the FY26 Q3 export's Parent Account ID column is Excel-corrupted), +maps product families → capability ids via `data/product_capability_map.json`, +and writes: + +- `data/accounts.json` — `currentProducts` (readable, fed to LLM prompts) + + `ownedCapabilityIds` (used for local anti-signal checks) +- `data/entitlements.json` (**gitignored**) — full per-account detail incl. + next renewal dates +- `output/state/entitlement_unmapped.json` — product families with no + capability mapping yet; extend `product_capability_map.json` from this + +During the loop, a brief for an already-owned capability is tagged +**⬆ EXPANSION** (local set lookup, no LLM call). The ACL CSV and +entitlements.json are gitignored and never leave the machine. + +--- + +## Configuration (`.env`) + +### LLM provider + +| Var | Default | Meaning | +|---|---|---| +| `LLM_PROVIDER` | auto | `anthropic` \| `openai` \| `gemini` (auto-detects from keys if blank) | +| `LLM_MODEL` | `claude-sonnet-5` | Anthropic model id | +| `ANTHROPIC_EFFORT` | `low` | Reasoning effort: `low` \| `medium` \| `high` \| `max` | +| `ANTHROPIC_USE_KEYCHAIN` | `1` | Read the Claude Code OAuth token from the macOS keychain (kept fresh by Claude Code; refresh with any `claude -p ...` call if expired) | +| `ANTHROPIC_OAUTH_TOKEN` | — | Explicit OAuth token (overrides keychain) | +| `ANTHROPIC_API_KEY` | — | Classic API key (used only if no OAuth token available) | +| `OPENAI_BASE_URL` | — | Any OpenAI-compatible `/chat/completions` endpoint (NVIDIA, LM Studio, ... — commented examples in `.env`) | +| `OPENAI_API_KEY` / `OPENAI_MODEL` | — | Credentials/model for that endpoint | +| `OPENAI_JSON_MODE` | `1` | Send `response_format=json_object` (0 if endpoint rejects it) | +| `OPENAI_TEMPERATURE` | `0.5` | Sampling (OpenAI-compatible only) | +| `OPENAI_FREQUENCY_PENALTY` | `0.4` | Anti-repetition (OpenAI-compatible only) | +| `GEMINI_API_KEY` / `GEMINI_MODEL` | — | Gemini credentials/model | +| `LLM_RATELIMIT_WAIT_SECONDS` | `900` | Wait per 429 before retry (honors `Retry-After`) | +| `LLM_RATELIMIT_MAX_RETRIES` | `24` | Max 429 waits before skipping the account | +| `LLM_CONTENT_RETRIES` | `3` | Re-samples on degenerate/unparseable model output | + +**Switching providers** is a `.env` edit only: set `LLM_PROVIDER`, uncomment the +relevant block (the NVIDIA endpoint is preserved, commented, for switch-back). + +### Signal sources + +| Var | Default | Meaning | +|---|---|---| +| `EXA_API_KEY` | — | Exa.ai key (news search) | +| `EXA_ENABLED` | `1` | `0` forces fixtures (no API calls) | +| `EXA_MONTHLY_REQUEST_CAP` | `1000` | Hard monthly ceiling (persistent counter) | +| `EXA_PER_RUN_REQUEST_CAP` | `25` | Hard per-run ceiling | +| `EXA_RESULTS_PER_QUERY` | `5` | Articles per account per run | +| `EXA_LOOKBACK_DAYS` | `45` | News/filings recency window | +| `EXA_CATEGORY` | `news` | Exa category filter (`""` disables) — keeps out careers/marketing pages | +| `EXA_DOMAIN_FILTER` | `0` | `1` = first-party newsroom only | +| `EXA_EXCLUDE_DOMAINS` | — | Comma-separated hosts to always exclude | +| `SEC_ENABLED` | `1` | SEC EDGAR filings harvester (free, public accounts auto-detected via CIK match) | +| `SEC_MAX_FILINGS_PER_ACCOUNT` | `5` | Max recent filings per account per run | + +### Scoring + +| Var | Default | Meaning | +|---|---|---| +| `PRIORITY_THRESHOLD` | `0.72` | Composite score for priority (immediate) delivery | +| `STANDARD_THRESHOLD` | `0.45` | Composite score for standard (digest) delivery | +| `CONFIDENCE_FLOOR` | `0.35` | Briefs the LLM itself scores below this are logged, never delivered | +| `RENEWAL_WINDOW_DAYS` | `365` | Renewal proximity window for the score boost | +| `RENEWAL_BOOST_MAX` | `0.10` | Max boost at renewal date (same-capability; half for account-level) | + +Composite formula: `signalStrength*0.30 + llmConfidence*0.20 + accountTier*0.20 + +buyerSeniority*0.15 + timingUrgency*0.10 + repFeedback*0.05`. + +--- + +## Output layout + +``` +output/ +├── briefs/ # delivered briefs (Slack-style markdown) +├── digests/ # HTML digests (digest__.html) +├── digest.md # latest text digest (convenience copy) +├── digest__