Anthropic OAuth LLM, entitlements (feature 1), renewals, + 7 enhancements
- LLM: Anthropic Sonnet 5 (effort=low) via OAuth Bearer (Claude Code keychain token, expiry-aware); NVIDIA/LM Studio endpoints kept commented for switch-back. - Entitlements: scripts/import_entitlements.py joins ACL export to accounts by normalized parent name (324/342 matched; ID column Excel-corrupted), maps products->capabilities via data/product_capability_map.json. Raw ACL csv and entitlements.json are gitignored (confidential); accounts.json carries derived currentProducts + ownedCapabilityIds. Owned capabilities tag briefs EXPANSION. - Renewals: local renewal-proximity score boost (window 365d, max +0.10) + 'renewals' report; briefs/digests show RENEWAL WINDOW context. - Batch runs: loop --limit/--rep with persistent cursor (strategic + stale-first). - Digests: per-rep HTML export; timestamped digest_<date>_<time>.md kept forever. - Account narrative memory fed into relevance/synthesis; synthesis maintains it. - Signal quality gate drops PR fluff pre-LLM; SEC EDGAR filings connector. - Citation verification: talking points must cite [S#] signals or are dropped. - Brief filenames use account-name slug; whitespace report + installed-base view. - USAGE.md: full command/flag/env reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
5
sales-agent/.gitignore
vendored
5
sales-agent/.gitignore
vendored
@@ -2,3 +2,8 @@ output/
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Confidential entitlement data — never commit
|
||||
data/SE_ACL*.csv
|
||||
data/*ACL*.csv
|
||||
data/entitlements.json
|
||||
|
||||
235
sales-agent/USAGE.md
Normal file
235
sales-agent/USAGE.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Usage Reference — Sales Prospecting Agent Loop
|
||||
|
||||
All commands run from `sales-agent/`: `python3 run.py <command> [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_<rep>_<date>.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 <BRIEF_ID> --action act_on
|
||||
python3 run.py feedback --brief <BRIEF_ID> --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_<rep>_<date>.html)
|
||||
├── digest.md # latest text digest (convenience copy)
|
||||
├── digest_<date>_<time>.md # timestamped digests (every run kept)
|
||||
├── whitespace.md # latest coverage report
|
||||
└── state/
|
||||
├── run_cursor.json # per-account last-scanned timestamps (--limit batching)
|
||||
├── memory_<accountId>.json # per-account memory incl. accountNarrative
|
||||
├── briefs_index.json # all briefs + status
|
||||
├── feedback.json # rep feedback records
|
||||
├── scoring_weights.json # recalibrated weights
|
||||
├── quality_gate_log.json # last 500 signals dropped pre-LLM (tune the gate here)
|
||||
├── exa_usage.json # Exa monthly request counter
|
||||
├── sec_company_tickers.json # SEC company->CIK table (cached 7 days)
|
||||
└── sec_cik_map.json # accountId->CIK resolution cache (incl. non-public misses)
|
||||
```
|
||||
|
||||
## Typical cadences
|
||||
|
||||
```bash
|
||||
# Nightly (cron): walk the territory 30 accounts at a time
|
||||
python3 run.py loop --limit 30
|
||||
|
||||
# Monday morning: coverage review + per-rep digests
|
||||
python3 run.py whitespace
|
||||
python3 run.py digest --rep "Misty Brew" --html
|
||||
|
||||
# Weekly: fold rep feedback into scoring
|
||||
python3 run.py recalibrate
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
142
sales-agent/data/accounts.pilot-backup.json
Normal file
142
sales-agent/data/accounts.pilot-backup.json
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"_note": "Pilot target list (~200 total to come). REAL fields: accountName, domain, industry, crmTier (verbatim CRM tier where known). crmTier->accountTier mapping used by the 3-band scorer: Tier 1->strategic, Tier 2->enterprise, Tier 3->growth, Tier 4->growth (NOTE: Tier 3 & 4 currently collapse to the same scoring weight — extend scorer to 4 bands to distinguish). PLACEHOLDER fields to confirm from CRM/install-base: assignedRep, currentProducts (drives anti-signal suppression — empty means nothing suppressed yet), targetContacts (empty; LLM/signal authors identify the ideal contact, CRM enrichment fills the rest).",
|
||||
"accounts": [
|
||||
{
|
||||
"accountId": "acct_lowes",
|
||||
"accountName": "Lowe's Companies",
|
||||
"domain": "lowes.com",
|
||||
"industry": "Retail (Home Improvement)",
|
||||
"accountTier": "strategic",
|
||||
"assignedRep": { "id": "rep_sarah", "name": "Sarah K.", "email": "sarah.k@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_duke_energy",
|
||||
"accountName": "Duke Energy",
|
||||
"domain": "duke-energy.com",
|
||||
"industry": "Energy & Utilities",
|
||||
"accountTier": "strategic",
|
||||
"assignedRep": { "id": "rep_priya", "name": "Priya R.", "email": "priya.r@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_fedex",
|
||||
"accountName": "FedEx",
|
||||
"domain": "fedex.com",
|
||||
"industry": "Transportation & Logistics",
|
||||
"accountTier": "strategic",
|
||||
"assignedRep": { "id": "rep_sarah", "name": "Sarah K.", "email": "sarah.k@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_walmart",
|
||||
"accountName": "Walmart",
|
||||
"domain": "walmart.com",
|
||||
"industry": "Retail",
|
||||
"accountTier": "strategic",
|
||||
"assignedRep": { "id": "rep_sarah", "name": "Sarah K.", "email": "sarah.k@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_hca",
|
||||
"accountName": "HCA Healthcare",
|
||||
"domain": "hcahealthcare.com",
|
||||
"industry": "Healthcare",
|
||||
"accountTier": "strategic",
|
||||
"assignedRep": { "id": "rep_marcus", "name": "Marcus L.", "email": "marcus.l@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_chs",
|
||||
"accountName": "Community Health Systems",
|
||||
"domain": "chs.net",
|
||||
"industry": "Healthcare",
|
||||
"accountTier": "enterprise",
|
||||
"assignedRep": { "id": "rep_marcus", "name": "Marcus L.", "email": "marcus.l@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_acxiom",
|
||||
"accountName": "Acxiom",
|
||||
"domain": "acxiom.com",
|
||||
"industry": "Marketing & Data Services",
|
||||
"accountTier": "enterprise",
|
||||
"assignedRep": { "id": "rep_priya", "name": "Priya R.", "email": "priya.r@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_booz_allen",
|
||||
"accountName": "Booz Allen Hamilton",
|
||||
"domain": "boozallen.com",
|
||||
"industry": "Consulting & Government Services",
|
||||
"crmTier": "Tier 3",
|
||||
"accountTier": "growth",
|
||||
"assignedRep": { "id": "rep_priya", "name": "Priya R.", "email": "priya.r@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_dominion",
|
||||
"accountName": "Dominion Energy",
|
||||
"domain": "dominionenergy.com",
|
||||
"industry": "Energy & Utilities",
|
||||
"crmTier": "Tier 1",
|
||||
"accountTier": "strategic",
|
||||
"assignedRep": { "id": "rep_priya", "name": "Priya R.", "email": "priya.r@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_hope_gas",
|
||||
"accountName": "Hope Gas Holdings",
|
||||
"domain": "hopegas.com",
|
||||
"industry": "Energy & Utilities (Natural Gas)",
|
||||
"crmTier": "Tier 3",
|
||||
"accountTier": "growth",
|
||||
"assignedRep": { "id": "rep_priya", "name": "Priya R.", "email": "priya.r@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_markel",
|
||||
"accountName": "Markel",
|
||||
"domain": "markel.com",
|
||||
"industry": "Insurance",
|
||||
"crmTier": "Tier 3",
|
||||
"accountTier": "growth",
|
||||
"assignedRep": { "id": "rep_marcus", "name": "Marcus L.", "email": "marcus.l@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
},
|
||||
{
|
||||
"accountId": "acct_gw_mfa",
|
||||
"accountName": "GW Medical Faculty Associates",
|
||||
"domain": "gwdocs.com",
|
||||
"industry": "Healthcare",
|
||||
"crmTier": "Tier 4",
|
||||
"accountTier": "growth",
|
||||
"assignedRep": { "id": "rep_marcus", "name": "Marcus L.", "email": "marcus.l@yourco.com" },
|
||||
"currentProducts": [],
|
||||
"targetContacts": [],
|
||||
"lastRunAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
48
sales-agent/data/product_capability_map.json
Normal file
48
sales-agent/data/product_capability_map.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"_note": "Maps entitlement product taxonomy (Product BU / Family / Midfamily / Subfamily substrings, case-insensitive regex) to capability ids. Matching runs LOCALLY in scripts/import_entitlements.py — no LLM. Families that match nothing are logged to output/state/entitlement_unmapped.json; extend this map from that log.",
|
||||
"patterns": [
|
||||
{"match": "VSAN", "capability": "cap_vmw_vsan"},
|
||||
{"match": "VSPHERE|VVS|VVEP|ESXI", "capability": "cap_vmw_vsphere"},
|
||||
{"match": "\\bVCF\\b|CLOUD FOUNDATION", "capability": "cap_vmw_vcf"},
|
||||
{"match": "NSX|VDEFEND", "capability": "cap_vmw_nsx"},
|
||||
{"match": "\\bAVI\\b|LOAD BALANC", "capability": "cap_vmw_avi"},
|
||||
{"match": "ARIA|VREALIZE|VCF OPS|OPERATIONS MGMT", "capability": "cap_vmw_aria"},
|
||||
{"match": "TANZU|KUBERNETES", "capability": "cap_vmw_tanzu"},
|
||||
{"match": "WORKSPACE ONE|WS1|AIRWATCH", "capability": "cap_vmw_workspaceone"},
|
||||
{"match": "HORIZON", "capability": "cap_vmw_horizon"},
|
||||
{"match": "SITE RECOVERY|LIVE RECOVERY|SRM|RANSOMWARE", "capability": "cap_vmw_live_recovery"},
|
||||
{"match": "\\bHCX\\b", "capability": "cap_vmw_hcx"},
|
||||
{"match": "PRIVATE AI", "capability": "cap_vmw_private_ai"},
|
||||
|
||||
{"match": "ENDEVOR", "capability": "cap_mf_endevor"},
|
||||
{"match": "BRIGHTSIDE|ZOWE", "capability": "cap_mf_brightside"},
|
||||
{"match": "\\bIDMS\\b", "capability": "cap_mf_idms"},
|
||||
{"match": "DATACOM", "capability": "cap_mf_datacom"},
|
||||
{"match": "DB2", "capability": "cap_mf_db2tools"},
|
||||
{"match": "TOP SECRET|ACF2|ACF-2", "capability": "cap_mf_security"},
|
||||
{"match": "COMPLIANCE EVENT|DATA CONTENT DISCOVERY|CLEANUP", "capability": "cap_mf_compliance"},
|
||||
{"match": "OPS/MVS|OPSMVS", "capability": "cap_mf_automation"},
|
||||
{"match": "SYSVIEW", "capability": "cap_mf_sysview"},
|
||||
{"match": "MAINFRAME OPERATIONAL INTELLIGENCE|\\bMOI\\b", "capability": "cap_mf_aiops"},
|
||||
{"match": "CA 7|CA7|CA-7", "capability": "cap_mf_workload_automation"},
|
||||
{"match": "NETMASTER", "capability": "cap_mf_network"},
|
||||
{"match": "VANTAGE|DISK BACKUP", "capability": "cap_mf_storage"},
|
||||
|
||||
{"match": "RALLY|AGILE CENTRAL", "capability": "cap_es_rally"},
|
||||
{"match": "CLARITY|PPM", "capability": "cap_es_clarity"},
|
||||
{"match": "VALUEOPS|CONNECTALL", "capability": "cap_es_valueops_insights"},
|
||||
{"match": "\\bAPM\\b|APP.*PERFORMANCE|INTROSCOPE|WILY", "capability": "cap_es_dx_apm"},
|
||||
{"match": "DX OPERATIONAL|DX AIOPS|DX O2", "capability": "cap_es_dx_aiops"},
|
||||
{"match": "NETOPS|SPECTRUM|PERFORMANCE MGMT.*NET", "capability": "cap_es_dx_netops"},
|
||||
{"match": "APPNETA", "capability": "cap_es_appneta"},
|
||||
{"match": "AUTOMIC|AUTOSYS|WORKLOAD AUTOMATION", "capability": "cap_es_automation"},
|
||||
{"match": "LAYER ?7|API MANAGEMENT|API GATEWAY", "capability": "cap_es_layer7"},
|
||||
|
||||
{"match": "SITEMINDER|ACCESS MANAGEMENT|\\bSSO\\b", "capability": "cap_iam_siteminder"},
|
||||
{"match": "\\bVIP\\b.*AUTH|SYMANTEC VIP", "capability": "cap_iam_vip"},
|
||||
{"match": "\\bPAM\\b|PRIVILEGED", "capability": "cap_iam_pam"},
|
||||
{"match": "IDENTITY (GOVERNANCE|SUITE|MANAGER)|\\bIGA\\b", "capability": "cap_iam_iga"},
|
||||
{"match": "DIRECTORY", "capability": "cap_iam_directory"},
|
||||
{"match": "ADVANCED AUTH|AUTH HUB", "capability": "cap_iam_advauth"}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import sys
|
||||
from src import config, pipeline
|
||||
from src.connectors import crm, exa
|
||||
from src.delivery import digest as digest_mod
|
||||
from src.delivery import whitespace as whitespace_mod
|
||||
from src.memory import store, feedback
|
||||
|
||||
|
||||
@@ -25,15 +26,59 @@ def cmd_loop(args):
|
||||
config.EXA_DOMAIN_FILTER = False # per-run override
|
||||
if args.domain_filter:
|
||||
config.EXA_DOMAIN_FILTER = True
|
||||
pipeline.run_all(tier=args.tier, account_id=args.account)
|
||||
pipeline.run_all(tier=args.tier, account_id=args.account,
|
||||
limit=args.limit, rep=args.rep)
|
||||
print("\n--- digest preview ---")
|
||||
print(digest_mod.write_digest())
|
||||
|
||||
|
||||
def cmd_digest(args):
|
||||
if args.html:
|
||||
path = digest_mod.write_digest_html(rep=args.rep)
|
||||
print(f"HTML digest written: {path}")
|
||||
else:
|
||||
print(digest_mod.write_digest(rep_id=args.rep))
|
||||
|
||||
|
||||
def cmd_whitespace(args):
|
||||
print(whitespace_mod.write_report())
|
||||
print(f"(also written to {config.OUTPUT_DIR / 'whitespace.md'})")
|
||||
|
||||
|
||||
def cmd_renewals(args):
|
||||
"""Upcoming renewals across the territory (local entitlement data)."""
|
||||
import json as _json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
p = config.DATA_DIR / "entitlements.json"
|
||||
if not p.exists():
|
||||
print("No entitlement data. Run: python3 scripts/import_entitlements.py <ACL.csv>")
|
||||
return
|
||||
ent = _json.loads(p.read_text())
|
||||
now = datetime.now(timezone.utc)
|
||||
horizon = args.months * 31
|
||||
by_month = defaultdict(list)
|
||||
for aid, e in ent.items():
|
||||
for prod in e["products"]:
|
||||
d = prod.get("nextRenewal")
|
||||
if not d:
|
||||
continue
|
||||
days = (datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=timezone.utc) - now).days
|
||||
if 0 <= days <= horizon:
|
||||
by_month[d[:7]].append((d, e["accountName"], prod["product"], prod["lines"]))
|
||||
print(f"💰 UPCOMING RENEWALS — next {args.months} month(s)\n")
|
||||
if not by_month:
|
||||
print(" none in window")
|
||||
for month in sorted(by_month):
|
||||
items = sorted(by_month[month])
|
||||
print(f"── {month} ({len(items)} renewal line-group(s)) " + "─" * 20)
|
||||
for d, name, prod, lines in items[:args.limit]:
|
||||
print(f" {d} {name[:32]:32} {prod[:58]} ({lines} lines)")
|
||||
if len(items) > args.limit:
|
||||
print(f" ... and {len(items) - args.limit} more (use --limit to widen)")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_feedback(args):
|
||||
entry = feedback.record_feedback(args.brief, args.action, args.note or "")
|
||||
print(f"Recorded '{args.action}' on brief {args.brief}. "
|
||||
@@ -52,8 +97,10 @@ def cmd_recalibrate(args):
|
||||
|
||||
def cmd_accounts(args):
|
||||
for a in crm.load_accounts():
|
||||
prods = a.get("currentProducts") or []
|
||||
has = f"{len(prods)} products, {len(a.get('ownedCapabilityIds') or [])} caps" if prods else "-"
|
||||
print(f" {a['accountId']:18} {a['accountTier']:10} {a['accountName']:28} "
|
||||
f"rep={a['assignedRep']['name']} has={a.get('currentProducts') or '[]'}")
|
||||
f"rep={a['assignedRep']['name']} owns: {has}")
|
||||
|
||||
|
||||
def cmd_briefs(args):
|
||||
@@ -86,6 +133,10 @@ def main(argv=None):
|
||||
p_loop = sub.add_parser("loop", help="Run the harvest->deliver loop")
|
||||
p_loop.add_argument("--account", help="single account id")
|
||||
p_loop.add_argument("--tier", choices=["strategic", "enterprise", "growth"])
|
||||
p_loop.add_argument("--rep", help="only accounts assigned to this rep (name or id substring)")
|
||||
p_loop.add_argument("--limit", type=int,
|
||||
help="process at most N accounts (strategic + least-recently-run first); "
|
||||
"successive runs resume where the last left off")
|
||||
p_loop.add_argument("--no-domain-filter", action="store_true",
|
||||
help="broad-web news about the account (default behavior)")
|
||||
p_loop.add_argument("--domain-filter", action="store_true",
|
||||
@@ -93,9 +144,20 @@ def main(argv=None):
|
||||
p_loop.set_defaults(func=cmd_loop)
|
||||
|
||||
p_dig = sub.add_parser("digest", help="Print the daily intelligence digest")
|
||||
p_dig.add_argument("--rep", help="filter to a rep id")
|
||||
p_dig.add_argument("--rep", help="filter to a rep (name substring or rep id)")
|
||||
p_dig.add_argument("--html", action="store_true",
|
||||
help="write a forwardable HTML digest to output/digests/")
|
||||
p_dig.set_defaults(func=cmd_digest)
|
||||
|
||||
sub.add_parser("whitespace",
|
||||
help="Territory coverage report: opportunities, gaps, never-scanned accounts"
|
||||
).set_defaults(func=cmd_whitespace)
|
||||
|
||||
p_ren = sub.add_parser("renewals", help="Upcoming renewals by month (local entitlement data)")
|
||||
p_ren.add_argument("--months", type=int, default=6, help="horizon in months (default 6)")
|
||||
p_ren.add_argument("--limit", type=int, default=15, help="max rows shown per month (default 15)")
|
||||
p_ren.set_defaults(func=cmd_renewals)
|
||||
|
||||
p_fb = sub.add_parser("feedback", help="Record rep feedback on a brief")
|
||||
p_fb.add_argument("--brief", required=True)
|
||||
p_fb.add_argument("--action", required=True,
|
||||
|
||||
123
sales-agent/scripts/import_accounts.py
Normal file
123
sales-agent/scripts/import_accounts.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build data/accounts.json from the ISG territory CSV export.
|
||||
|
||||
Mapping (decided with the rep):
|
||||
accountId <- OEC Account Number (canonical; ties to entitlements)
|
||||
accountName <- Customer Company Name
|
||||
domain <- Website host (fallback: Authorized Email Domain)
|
||||
industry <- Licensee Industry (fallback: Parent Account Industry)
|
||||
accountTier <- Classification: Strategic->strategic, Corporate->enterprise
|
||||
assignedRep <- Owner; email synthesized first.last@broadcom.com
|
||||
targetContacts <- Primary Contact / Contact Email / Contact Phone (if present)
|
||||
reference <- parent + account IDs and CRM metadata for entitlement matching
|
||||
|
||||
Usage: python3 scripts/import_accounts.py /path/to/territory.csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "data" / "accounts.json"
|
||||
|
||||
TIER_MAP = {"strategic": "strategic", "corporate": "enterprise"}
|
||||
EMAIL_DOMAIN = "broadcom.com"
|
||||
|
||||
|
||||
def clean_domain(website: str, email_domain: str) -> str:
|
||||
w = (website or "").strip()
|
||||
if w:
|
||||
w = re.sub(r"^https?://", "", w, flags=re.I)
|
||||
w = re.sub(r"^www\.", "", w, flags=re.I)
|
||||
w = w.split("/")[0].strip().lower()
|
||||
if "." in w:
|
||||
return w
|
||||
return (email_domain or "").strip().lower()
|
||||
|
||||
|
||||
def rep_identity(owner: str) -> dict:
|
||||
name = (owner or "").strip()
|
||||
if not name:
|
||||
return {"id": "rep_unassigned", "name": "(unassigned)", "email": ""}
|
||||
tokens = [re.sub(r"[^a-z0-9]", "", t.lower()) for t in name.split()]
|
||||
tokens = [t for t in tokens if t]
|
||||
slug = "_".join(tokens)
|
||||
# first.last for the email local-part (collapse middle tokens out)
|
||||
local = f"{tokens[0]}.{tokens[-1]}" if len(tokens) >= 2 else tokens[0]
|
||||
return {"id": f"rep_{slug}", "name": name, "email": f"{local}@{EMAIL_DOMAIN}"}
|
||||
|
||||
|
||||
def target_contacts(row: dict) -> list[dict]:
|
||||
name = (row.get("Primary Contact") or "").strip()
|
||||
email = (row.get("Contact Email") or "").strip()
|
||||
phone = (row.get("Contact Phone") or "").strip()
|
||||
if not (name or email):
|
||||
return []
|
||||
return [{
|
||||
"name": name or "(primary contact)",
|
||||
"email": email,
|
||||
"phone": phone,
|
||||
"inCrm": True,
|
||||
}]
|
||||
|
||||
|
||||
def main(csv_path: str) -> None:
|
||||
rows = list(csv.DictReader(open(csv_path, newline="", encoding="utf-8-sig")))
|
||||
accounts = []
|
||||
for r in rows:
|
||||
g = lambda k: (r.get(k) or "").strip()
|
||||
classification = g("Classification")
|
||||
accounts.append({
|
||||
"accountId": g("OEC Account Number"),
|
||||
"accountName": g("Customer Company Name"),
|
||||
"domain": clean_domain(g("Website"), g("Authorized Email Domain")),
|
||||
"industry": g("Licensee Industry") or g("Parent Account Industry"),
|
||||
"accountTier": TIER_MAP.get(classification.lower(), "enterprise"),
|
||||
"crmClassification": classification,
|
||||
"subClass": g("Sub Class"),
|
||||
"assignedRep": rep_identity(g("Owner")),
|
||||
"currentProducts": [],
|
||||
"targetContacts": target_contacts(r),
|
||||
"reference": {
|
||||
"oecAccountNumber": g("OEC Account Number"),
|
||||
"erpAccountNumber": g("Erp Account Number"),
|
||||
"parentId": g("Parent ID"),
|
||||
"parentName": g("Rpt Parent Name") or g("Parent Account"),
|
||||
"globalParentId": g("Global Parent ID"),
|
||||
"globalParentName": g("Global Parent Name"),
|
||||
"classification": classification,
|
||||
"subClass": g("Sub Class"),
|
||||
"salesRegion": g("Sales Region"),
|
||||
"salesArea": g("Sales Area"),
|
||||
"salesTerritory": g("Sales Territory"),
|
||||
"renewalOwner": g("Renewal Owner"),
|
||||
"country": g("Country"),
|
||||
"state": g("State"),
|
||||
"website": g("Website"),
|
||||
"emailDomain": g("Authorized Email Domain"),
|
||||
},
|
||||
"lastRunAt": None,
|
||||
})
|
||||
|
||||
doc = {
|
||||
"_note": (
|
||||
"Imported from ISG Customer Sales Assignments territory CSV "
|
||||
"(scripts/import_accounts.py). accountId = OEC Account Number. "
|
||||
"Tier: Classification Strategic->strategic, Corporate->enterprise "
|
||||
"(verbatim in crmClassification/subClass). Rep email synthesized "
|
||||
"first.last@broadcom.com (UNVERIFIED). reference.parentId is the "
|
||||
"Parent Account ID for future product-entitlement matching. "
|
||||
"currentProducts empty until entitlement data is loaded."
|
||||
),
|
||||
"accounts": accounts,
|
||||
}
|
||||
OUT.write_text(json.dumps(doc, indent=2))
|
||||
print(f"wrote {len(accounts)} accounts -> {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
163
sales-agent/scripts/import_entitlements.py
Normal file
163
sales-agent/scripts/import_entitlements.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Load product entitlements (ACL export) into the account list — 100% locally.
|
||||
|
||||
CONFIDENTIAL-DATA HANDLING: the ACL CSV and the generated entitlements.json are
|
||||
gitignored and are never sent to any API. All matching below is deterministic
|
||||
local string work (no LLM).
|
||||
|
||||
NOTE: the FY26 Q3 export's 'Parent Account ID' column is Excel-corrupted
|
||||
('3E+14' scientific notation), so accounts are joined on NORMALIZED PARENT NAME
|
||||
(ACL 'Rpt Parent Name'/'Parent Account Name' vs accounts.json reference.parentName
|
||||
/ accountName). Re-exporting without Excel would restore the clean ID join.
|
||||
|
||||
Outputs:
|
||||
- data/entitlements.json (gitignored): per-account product detail + renewals
|
||||
- data/accounts.json updated in place: currentProducts (readable strings)
|
||||
+ ownedCapabilityIds (for local anti-signal checks)
|
||||
- output/state/entitlement_unmapped.json: product families that matched no
|
||||
capability pattern (extend data/product_capability_map.json from this)
|
||||
|
||||
Usage: python3 scripts/import_entitlements.py data/SE_ACL_FY26_Q3.csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACCOUNTS = ROOT / "data" / "accounts.json"
|
||||
CAP_MAP = ROOT / "data" / "product_capability_map.json"
|
||||
OUT_ENTITLEMENTS = ROOT / "data" / "entitlements.json"
|
||||
OUT_UNMAPPED = ROOT / "output" / "state" / "entitlement_unmapped.json"
|
||||
|
||||
|
||||
def norm_name(name: str) -> str:
|
||||
"""Normalize a company/parent name for joining across systems."""
|
||||
n = name.upper()
|
||||
n = re.sub(r"\(GP\)", "", n)
|
||||
n = re.sub(r"[^A-Z0-9 ]", "", n) # drops hyphens: FED-EX -> FEDEX
|
||||
n = re.sub(
|
||||
r"\b(INC|CORP|CORPORATION|CO|COMPANY|COMPANIES|HOLDINGS?|GROUP|PLC|LTD|LLC|THE)\b",
|
||||
"", n)
|
||||
return re.sub(r"\s+", " ", n).strip()
|
||||
|
||||
|
||||
def load_cap_patterns() -> list[tuple[re.Pattern, str]]:
|
||||
doc = json.loads(CAP_MAP.read_text())
|
||||
return [(re.compile(p["match"], re.IGNORECASE), p["capability"]) for p in doc["patterns"]]
|
||||
|
||||
|
||||
def parse_date(us_date: str) -> str:
|
||||
"""'7/28/26' -> '2026-07-28' (blank-safe)."""
|
||||
try:
|
||||
return datetime.strptime(us_date.strip(), "%m/%d/%y").strftime("%Y-%m-%d")
|
||||
except (ValueError, AttributeError):
|
||||
return ""
|
||||
|
||||
|
||||
def main(csv_path: str) -> None:
|
||||
patterns = load_cap_patterns()
|
||||
accounts_doc = json.loads(ACCOUNTS.read_text())
|
||||
accounts = accounts_doc["accounts"]
|
||||
|
||||
# Index accounts by normalized parent name AND normalized account name.
|
||||
by_norm: dict[str, dict] = {}
|
||||
for a in accounts:
|
||||
for candidate in (a.get("reference", {}).get("parentName", ""), a["accountName"]):
|
||||
key = norm_name(candidate)
|
||||
if key and key not in by_norm:
|
||||
by_norm[key] = a
|
||||
|
||||
# Aggregate active entitlement lines per normalized parent name.
|
||||
per_parent: dict[str, dict] = defaultdict(lambda: {
|
||||
"parentNames": set(), "products": {}, "lines": 0,
|
||||
})
|
||||
total_lines = active_lines = 0
|
||||
for row in csv.DictReader(open(csv_path, newline="", encoding="utf-8-sig")):
|
||||
total_lines += 1
|
||||
if (row.get("ACL Status") or "").strip().lower() != "active":
|
||||
continue
|
||||
active_lines += 1
|
||||
parent = (row.get("Rpt Parent Name") or row.get("Parent Account Name") or "").strip()
|
||||
key = norm_name(parent)
|
||||
if not key:
|
||||
continue
|
||||
agg = per_parent[key]
|
||||
agg["parentNames"].add(parent)
|
||||
agg["lines"] += 1
|
||||
prod_key = " / ".join(x for x in (
|
||||
(row.get("Product BU") or "").strip(),
|
||||
(row.get("Product Family") or "").strip(),
|
||||
(row.get("Product Subfamily") or "").strip(),
|
||||
) if x)
|
||||
p = agg["products"].setdefault(prod_key, {
|
||||
"bu": (row.get("Product BU") or "").strip(),
|
||||
"family": (row.get("Product Family") or "").strip(),
|
||||
"subfamily": (row.get("Product Subfamily") or "").strip(),
|
||||
"lines": 0, "licenseTypes": set(), "nextRenewal": "",
|
||||
})
|
||||
p["lines"] += 1
|
||||
p["licenseTypes"].add((row.get("License Type Calculated") or "").strip())
|
||||
rd = parse_date(row.get("Renewal Date", ""))
|
||||
if rd and (not p["nextRenewal"] or rd < p["nextRenewal"]):
|
||||
p["nextRenewal"] = rd
|
||||
|
||||
# Map products -> capabilities and join to accounts.
|
||||
entitlements: dict[str, dict] = {}
|
||||
unmapped: dict[str, int] = defaultdict(int)
|
||||
matched_accounts = 0
|
||||
for key, agg in per_parent.items():
|
||||
acct = by_norm.get(key)
|
||||
if not acct:
|
||||
continue
|
||||
matched_accounts += 1
|
||||
owned_caps: set[str] = set()
|
||||
products = []
|
||||
for prod_key, p in sorted(agg["products"].items()):
|
||||
probe = f"{p['bu']} {p['family']} {p['subfamily']}"
|
||||
caps = sorted({cap for rx, cap in patterns if rx.search(probe)})
|
||||
if not caps:
|
||||
unmapped[prod_key] += p["lines"]
|
||||
owned_caps.update(caps)
|
||||
products.append({
|
||||
"product": prod_key, "lines": p["lines"],
|
||||
"licenseTypes": sorted(t for t in p["licenseTypes"] if t),
|
||||
"nextRenewal": p["nextRenewal"], "capabilities": caps,
|
||||
})
|
||||
entitlements[acct["accountId"]] = {
|
||||
"accountName": acct["accountName"],
|
||||
"matchedOn": sorted(agg["parentNames"]),
|
||||
"activeLines": agg["lines"],
|
||||
"products": products,
|
||||
"ownedCapabilityIds": sorted(owned_caps),
|
||||
}
|
||||
# Update the account record: readable strings for LLM prompts,
|
||||
# capability ids for local anti-signal checks.
|
||||
acct["currentProducts"] = sorted({
|
||||
f"{p['family']}" + (f" ({p['subfamily']})" if p["subfamily"] and p["subfamily"] != p["family"] else "")
|
||||
for p in agg["products"].values()
|
||||
})
|
||||
acct["ownedCapabilityIds"] = sorted(owned_caps)
|
||||
|
||||
OUT_ENTITLEMENTS.write_text(json.dumps(entitlements, indent=2))
|
||||
ACCOUNTS.write_text(json.dumps(accounts_doc, indent=2))
|
||||
OUT_UNMAPPED.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_UNMAPPED.write_text(json.dumps(
|
||||
dict(sorted(unmapped.items(), key=lambda kv: -kv[1])), indent=2))
|
||||
|
||||
print(f"ACL lines read: {total_lines}")
|
||||
print(f" active: {active_lines}")
|
||||
print(f" distinct parents in file: {len(per_parent)}")
|
||||
print(f"Accounts matched: {matched_accounts}/{len(accounts)}")
|
||||
print(f" with owned capabilities: {sum(1 for e in entitlements.values() if e['ownedCapabilityIds'])}")
|
||||
print(f"Unmapped product families: {len(unmapped)} -> {OUT_UNMAPPED.relative_to(ROOT)}")
|
||||
print(f"Wrote {OUT_ENTITLEMENTS.relative_to(ROOT)} (gitignored) and updated data/accounts.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "data" / "SE_ACL_FY26_Q3.csv"))
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Phase 1: Signal Harvesting. Fans out across all source connectors per account."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..connectors import linkedin, news, jobs
|
||||
from ..connectors import linkedin, news, jobs, sec_edgar
|
||||
from ..types import RawSignal
|
||||
|
||||
|
||||
def run_harvesters(account: dict) -> list[RawSignal]:
|
||||
"""Equivalent of Promise.allSettled across source harvesters."""
|
||||
raw: list[RawSignal] = []
|
||||
for harvester in (linkedin.harvest, news.harvest, jobs.harvest):
|
||||
for harvester in (linkedin.harvest, news.harvest, jobs.harvest, sec_edgar.harvest):
|
||||
try:
|
||||
raw.extend(harvester(account))
|
||||
except Exception as e: # one bad source shouldn't kill the run
|
||||
|
||||
79
sales-agent/src/agents/quality_gate.py
Normal file
79
sales-agent/src/agents/quality_gate.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Signal-quality gate: cheap pre-LLM filter for PR fluff and market noise.
|
||||
|
||||
Drops signals that essentially never indicate a software buying opportunity —
|
||||
awards, sponsorships, CSR announcements, stock-movement chatter — BEFORE they
|
||||
consume rate-limited LLM calls. Conservative by design: a signal is dropped only
|
||||
when a noise pattern matches AND no business-event keyword is present.
|
||||
Tune via data-driven review of `output/state/quality_gate_log.json`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from .. import config
|
||||
from ..types import NormalizedSignal
|
||||
|
||||
# Noise: topics that don't signal a buying need.
|
||||
_NOISE = [
|
||||
r"\baward(s|ed)?\b", r"\bbest places? to work\b", r"\bgreat place to work\b",
|
||||
r"\brank(ed|ing)s?\b.*\blist\b", r"\bsponsor(s|ship|ing)?\b",
|
||||
r"\bdonat(es?|ion|ing)\b", r"\bcharit(y|able)\b", r"\bscholarship\b",
|
||||
r"\bvolunteer(s|ing)?\b", r"\bcommunity (event|outreach|giving)\b",
|
||||
r"\bfoundation grant\b", r"\bcelebrat(es?|ion|ing)\b.*\banniversary\b",
|
||||
r"\bstock (rose|fell|jumped|dipped|slid|gain(ed)?|drop(ped)?)\b",
|
||||
r"\bshares? (rose|fell|jumped|dipped|slid|surged|tumbled)\b",
|
||||
r"\bprice target\b", r"\banalyst (rating|upgrade|downgrade)\b",
|
||||
r"\bdividend (declar|announc)", r"\bbuyback\b",
|
||||
r"\b(is|are) it a buy\b", r"\bshould you buy\b", r"\bmotley fool\b",
|
||||
r"\bzacks\b", r"\bseeking alpha\b",
|
||||
r"\bgift guide\b", r"\bblack friday deal", r"\bcoupon(s)?\b",
|
||||
r"\brecipe(s)?\b", r"\bsweepstakes\b",
|
||||
]
|
||||
# Business events that KEEP a signal even if a noise pattern also matched.
|
||||
_KEEP = [
|
||||
r"\bacqui(re|res|red|sition)\b", r"\bmerger?\b", r"\bdivestiture\b", r"\bspin[- ]?off\b",
|
||||
r"\bearnings\b", r"\bquarterly results\b", r"\bguidance\b",
|
||||
r"\brestructur", r"\blayoffs?\b", r"\breorganiz",
|
||||
r"\b(names?|appoints?|hires?|taps)\b.*\b(cio|cto|ciso|cfo|ceo|chief|vp|president)\b",
|
||||
r"\b(cio|cto|ciso|cfo)\b", r"\bchief (information|technology|digital|security)\b",
|
||||
r"\bdata center\b", r"\bcloud\b", r"\bmainframe\b", r"\bmigrat(e|ion)\b",
|
||||
r"\bmoderniz", r"\bdigital transformation\b", r"\bautomat(e|ion|ing)\b",
|
||||
r"\bcybersecurity\b", r"\bbreach\b", r"\bransomware\b", r"\boutage\b",
|
||||
r"\bpartnership\b", r"\blaunch(es|ed)?\b", r"\bexpans?(ion|ds?)\b",
|
||||
r"\binvest(s|ment|ing)\b", r"\bregulat(or|ory|ion)\b", r"\bcompliance\b",
|
||||
r"\bAI\b", r"\bartificial intelligence\b", r"\berp\b", r"\bsap\b",
|
||||
]
|
||||
|
||||
_noise_re = [re.compile(p, re.IGNORECASE) for p in _NOISE]
|
||||
_keep_re = [re.compile(p, re.IGNORECASE) for p in _KEEP]
|
||||
|
||||
|
||||
def _log(dropped: list[dict]) -> None:
|
||||
if not dropped:
|
||||
return
|
||||
path = config.STATE_DIR / "quality_gate_log.json"
|
||||
log = json.loads(path.read_text()) if path.exists() else []
|
||||
log.extend(dropped)
|
||||
path.write_text(json.dumps(log[-500:], indent=2)) # keep last 500
|
||||
|
||||
|
||||
def filter_quality(signals: list[NormalizedSignal], verbose: bool = True) -> list[NormalizedSignal]:
|
||||
kept: list[NormalizedSignal] = []
|
||||
dropped: list[dict] = []
|
||||
for s in signals:
|
||||
text = f"{s.metadata.get('title', '')} {s.content[:400]}"
|
||||
noise_hit = next((p.pattern for p in _noise_re if p.search(text)), None)
|
||||
if noise_hit and not any(p.search(text) for p in _keep_re):
|
||||
dropped.append({
|
||||
"account_id": s.account_id, "signal_id": s.id,
|
||||
"title": s.metadata.get("title", "")[:120], "url": s.url,
|
||||
"matched_noise": noise_hit, "discovered_at": s.discovered_at,
|
||||
})
|
||||
continue
|
||||
kept.append(s)
|
||||
if verbose and dropped:
|
||||
print(f" [quality-gate] dropped {len(dropped)} noise signal(s) "
|
||||
f"(awards/CSR/ticker-chatter) before LLM — see state/quality_gate_log.json")
|
||||
_log(dropped)
|
||||
return kept
|
||||
@@ -24,6 +24,8 @@ BATCH_USER_TMPL = """ACCOUNT CONTEXT
|
||||
Company: {account_name}
|
||||
Current Products: {current_products}
|
||||
Account Tier: {account_tier}
|
||||
Account Narrative (rolling context from prior runs — use it to connect new
|
||||
signals to known strategic direction): {narrative}
|
||||
|
||||
CANDIDATE CAPABILITIES
|
||||
{capability_summary}
|
||||
@@ -92,6 +94,7 @@ def _evaluate_batch(prepared, account) -> list[tuple[NormalizedSignal, Relevance
|
||||
account_name=account["accountName"],
|
||||
current_products=", ".join(account.get("currentProducts", [])) or "none",
|
||||
account_tier=account["accountTier"],
|
||||
narrative=account.get("_narrative") or "(none yet — first run for this account)",
|
||||
capability_summary=capabilities.library_summary_for_prompt(list(cap_by_id.values())),
|
||||
signals_block=_signals_block(prepared),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from .. import config
|
||||
from ..types import OpportunityBrief, now_utc, parse_dt
|
||||
from ..memory import feedback
|
||||
from ..memory import feedback, entitlements
|
||||
|
||||
_TIER = {"strategic": 1.0, "enterprise": 0.66, "growth": 0.33}
|
||||
_SENIORITY = {"c-suite": 1.0, "evp": 1.0, "svp": 0.85, "vp": 0.7,
|
||||
@@ -81,6 +81,13 @@ def score_and_prioritize(briefs: list[OpportunityBrief], account: dict) -> list[
|
||||
+ s_timing * 0.10
|
||||
+ s_feedback * 0.05
|
||||
)
|
||||
# Renewal-proximity boost (local entitlement data, no LLM): an account
|
||||
# negotiating a renewal is a live commercial conversation to attach to.
|
||||
ctx = entitlements.renewal_context(b.account_id, b.capability_id)
|
||||
boost = entitlements.renewal_boost(ctx)
|
||||
if boost:
|
||||
composite = min(1.0, composite + boost)
|
||||
b.renewal_context = {**ctx, "scoreBoost": boost}
|
||||
b.composite_score = round(composite, 3)
|
||||
# Hard gate: the model's own low confidence keeps a brief out of delivery.
|
||||
if s_confidence < config.CONFIDENCE_FLOOR:
|
||||
|
||||
@@ -22,22 +22,32 @@ signal. Return JSON only."""
|
||||
# One call per account: every capability opportunity is synthesized together.
|
||||
BATCH_USER_TMPL = """ACCOUNT
|
||||
{account_name} (tier: {account_tier}); current products: {current_products}
|
||||
Prior account narrative: {narrative}
|
||||
|
||||
Produce ONE brief for EACH target capability below, using only that capability's
|
||||
validated signals.
|
||||
|
||||
CITATION RULE: every talking point MUST cite the signal number(s) it is based on
|
||||
via its "cites" list. A talking point that cannot be traced to a specific signal
|
||||
must not be written — no invented quotes, no invented numbers, no speculation.
|
||||
|
||||
{capability_blocks}
|
||||
|
||||
Also maintain the ACCOUNT NARRATIVE: return an updated 2-4 sentence rolling
|
||||
summary of this account's strategic situation, merging the prior narrative with
|
||||
what today's signals add. Factual, dated where possible, no fluff.
|
||||
|
||||
Return ONLY this JSON (one object per capabilityId):
|
||||
{{"briefs": [
|
||||
{{"capabilityId": str,
|
||||
"headline": str (one sentence),
|
||||
"executiveSummary": str (2-3 sentences for the rep),
|
||||
"talkingPoints": [str, str, str],
|
||||
"talkingPoints": [{{"text": str, "cites": [int signal numbers]}}, ...],
|
||||
"outreachHook": str (one opener for email/LinkedIn DM),
|
||||
"urgencyLevel": "high|medium|low",
|
||||
"confidenceScore": float 0..1}}
|
||||
]}}"""
|
||||
],
|
||||
"accountNarrative": str}}"""
|
||||
|
||||
|
||||
def _signals_block(items: list[tuple[NormalizedSignal, RelevanceResult]]) -> str:
|
||||
@@ -79,19 +89,47 @@ def _batched_bodies(groups: list[tuple[str, dict, list]], account: dict) -> dict
|
||||
account_name=account["accountName"],
|
||||
account_tier=account["accountTier"],
|
||||
current_products=", ".join(account.get("currentProducts", [])) or "none",
|
||||
narrative=account.get("_narrative") or "(none yet)",
|
||||
capability_blocks="\n\n".join(blocks),
|
||||
)
|
||||
data = llm.call_json(SYSTEM, user, max_tokens=4096, require_keys={"briefs"})
|
||||
return {
|
||||
data = llm.call_json(SYSTEM, user, max_tokens=6000, require_keys={"briefs"})
|
||||
bodies = {
|
||||
b["capabilityId"]: b
|
||||
for b in data.get("briefs", [])
|
||||
if isinstance(b, dict) and b.get("capabilityId")
|
||||
}
|
||||
return bodies, str(data.get("accountNarrative", "") or "")
|
||||
|
||||
|
||||
def _verify_citations(body: dict, n_signals: int, cap_name: str) -> list[str]:
|
||||
"""Citation verification: keep only talking points that cite a real signal.
|
||||
|
||||
Each point must cite >=1 in-range signal number; renders as 'text [S1,S2]'
|
||||
matching the numbered CONTRIBUTING SIGNALS list on the brief. Uncited or
|
||||
out-of-range points are dropped (never delivered) and logged.
|
||||
"""
|
||||
verified: list[str] = []
|
||||
dropped = 0
|
||||
for tp in body.get("talkingPoints", []) or []:
|
||||
if isinstance(tp, str): # model ignored the schema — treat as uncited
|
||||
dropped += 1
|
||||
continue
|
||||
text = str(tp.get("text", "")).strip()
|
||||
cites = sorted({int(c) for c in (tp.get("cites") or [])
|
||||
if isinstance(c, (int, float)) and 1 <= int(c) <= n_signals})
|
||||
if not text or not cites:
|
||||
dropped += 1
|
||||
continue
|
||||
verified.append(f"{text} [{', '.join(f'S{c}' for c in cites)}]")
|
||||
if dropped:
|
||||
print(f" [citations] dropped {dropped} unverifiable talking point(s) for {cap_name}")
|
||||
return verified
|
||||
|
||||
|
||||
def synthesize(
|
||||
relevant: list[tuple[NormalizedSignal, RelevanceResult]], account: dict
|
||||
) -> list[OpportunityBrief]:
|
||||
) -> tuple[list[OpportunityBrief], str]:
|
||||
"""Returns (briefs, updated_account_narrative)."""
|
||||
# Group by capability id.
|
||||
by_cap: dict[str, list[tuple[NormalizedSignal, RelevanceResult]]] = {}
|
||||
for s, r in relevant:
|
||||
@@ -107,10 +145,10 @@ def synthesize(
|
||||
items.sort(key=lambda x: order.get(x[1].signal_strength, 3)) # strongest first
|
||||
groups.append((cap_id, cap, items))
|
||||
if not groups:
|
||||
return []
|
||||
return [], ""
|
||||
|
||||
# One LLM call for all of this account's briefs (raises on failure -> caller skips).
|
||||
bodies = _batched_bodies(groups, account)
|
||||
bodies, narrative = _batched_bodies(groups, account)
|
||||
|
||||
briefs: list[OpportunityBrief] = []
|
||||
for cap_id, cap, items in groups:
|
||||
@@ -118,6 +156,11 @@ def synthesize(
|
||||
if not body:
|
||||
continue # model didn't return a brief for this capability — skip, don't fabricate
|
||||
|
||||
talking_points = _verify_citations(body, len(items), cap["name"])
|
||||
if not talking_points:
|
||||
print(f" [citations] no verifiable talking points for {cap['name']} — brief skipped")
|
||||
continue
|
||||
|
||||
created = now_utc()
|
||||
ideal = items[0][1].ideal_contact
|
||||
brief = OpportunityBrief(
|
||||
@@ -137,7 +180,7 @@ def synthesize(
|
||||
}
|
||||
for s, r in items
|
||||
],
|
||||
talking_points=body.get("talkingPoints", []),
|
||||
talking_points=talking_points,
|
||||
outreach_hook=body.get("outreachHook", ""),
|
||||
target_contacts=_target_contacts(account, ideal),
|
||||
urgency_level=body.get("urgencyLevel", "medium"),
|
||||
@@ -149,4 +192,4 @@ def synthesize(
|
||||
expires_at=iso(created + timedelta(days=config.BRIEF_TTL_DAYS)),
|
||||
)
|
||||
briefs.append(brief)
|
||||
return briefs
|
||||
return briefs, narrative
|
||||
|
||||
@@ -60,27 +60,37 @@ LLM_RATELIMIT_WAIT_SECONDS = int(os.environ.get("LLM_RATELIMIT_WAIT_SECONDS", "9
|
||||
LLM_RATELIMIT_MAX_RETRIES = int(os.environ.get("LLM_RATELIMIT_MAX_RETRIES", "24"))
|
||||
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-4-6")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5")
|
||||
# Anthropic OAuth (subscription auth instead of an API key). Token resolution:
|
||||
# ANTHROPIC_OAUTH_TOKEN env var, else the Claude Code credential in the macOS
|
||||
# keychain ("Claude Code-credentials"), read at runtime so refreshes are picked up.
|
||||
ANTHROPIC_OAUTH_TOKEN = os.environ.get("ANTHROPIC_OAUTH_TOKEN", "").strip()
|
||||
ANTHROPIC_USE_KEYCHAIN = os.environ.get("ANTHROPIC_USE_KEYCHAIN", "1").strip() == "1"
|
||||
# Effort level for reasoning models (Sonnet 5 etc.): low | medium | high | max.
|
||||
ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip()
|
||||
|
||||
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip()
|
||||
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
|
||||
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
|
||||
if not _provider:
|
||||
if OPENAI_API_KEY:
|
||||
if ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY:
|
||||
_provider = "anthropic"
|
||||
elif OPENAI_API_KEY:
|
||||
_provider = "openai"
|
||||
elif GEMINI_API_KEY:
|
||||
_provider = "gemini"
|
||||
elif ANTHROPIC_API_KEY:
|
||||
_provider = "anthropic"
|
||||
else:
|
||||
_provider = "none"
|
||||
# Selecting a provider whose key is missing => no usable provider.
|
||||
# ("anthropic" is exempt here: the token may come from the keychain at runtime.)
|
||||
if _provider == "openai" and not OPENAI_API_KEY:
|
||||
_provider = "none"
|
||||
elif _provider == "gemini" and not GEMINI_API_KEY:
|
||||
_provider = "none"
|
||||
elif _provider == "anthropic" and not ANTHROPIC_API_KEY:
|
||||
elif _provider == "anthropic" and not (
|
||||
ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_USE_KEYCHAIN
|
||||
):
|
||||
_provider = "none"
|
||||
|
||||
LLM_PROVIDER = _provider
|
||||
@@ -95,7 +105,8 @@ def llm_label() -> str:
|
||||
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
|
||||
if LLM_PROVIDER == "gemini":
|
||||
return f"Gemini ({GEMINI_MODEL})"
|
||||
return f"Anthropic ({LLM_MODEL})"
|
||||
auth = "OAuth" if (ANTHROPIC_OAUTH_TOKEN or not ANTHROPIC_API_KEY) else "API key"
|
||||
return f"Anthropic ({LLM_MODEL}, effort={ANTHROPIC_EFFORT}, {auth})"
|
||||
|
||||
# --- Scoring thresholds (Phase 5 of the design) ---
|
||||
PRIORITY_THRESHOLD = float(os.environ.get("PRIORITY_THRESHOLD", "0.72"))
|
||||
@@ -105,6 +116,13 @@ STANDARD_THRESHOLD = float(os.environ.get("STANDARD_THRESHOLD", "0.45"))
|
||||
# the model itself doubts out of the rep's queue.
|
||||
CONFIDENCE_FLOOR = float(os.environ.get("CONFIDENCE_FLOOR", "0.35"))
|
||||
|
||||
# --- Renewal proximity (from local entitlement data; no LLM) ---
|
||||
# A renewal within this window is a live commercial event: boost the brief's
|
||||
# score, scaled by how close the renewal is. Same-capability renewals get the
|
||||
# full boost; any-product renewals on the account get half.
|
||||
RENEWAL_WINDOW_DAYS = int(os.environ.get("RENEWAL_WINDOW_DAYS", "365"))
|
||||
RENEWAL_BOOST_MAX = float(os.environ.get("RENEWAL_BOOST_MAX", "0.10"))
|
||||
|
||||
# --- Exa.ai news/web harvester ---
|
||||
EXA_API_KEY = os.environ.get("EXA_API_KEY", "").strip()
|
||||
# Enabled only when a key is present AND not explicitly turned off.
|
||||
@@ -128,6 +146,10 @@ EXA_EXCLUDE_DOMAINS = [
|
||||
d.strip() for d in os.environ.get("EXA_EXCLUDE_DOMAINS", "").split(",") if d.strip()
|
||||
]
|
||||
|
||||
# --- SEC EDGAR filings harvester (free, no key) ---
|
||||
SEC_ENABLED = os.environ.get("SEC_ENABLED", "1").strip() == "1"
|
||||
SEC_MAX_FILINGS_PER_ACCOUNT = int(os.environ.get("SEC_MAX_FILINGS_PER_ACCOUNT", "5"))
|
||||
|
||||
# --- Dedup / suppression windows ---
|
||||
SEMANTIC_DEDUP_DAYS = 90
|
||||
SAME_TYPE_SUPPRESS_DAYS = 14
|
||||
|
||||
139
sales-agent/src/connectors/sec_edgar.py
Normal file
139
sales-agent/src/connectors/sec_edgar.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""SEC EDGAR filings harvester (free, no API key, no rate-limit pressure).
|
||||
|
||||
For public accounts, recent 8-K/10-K/10-Q filings are the highest-authority
|
||||
signal source available: 8-K items announce material events (acquisitions,
|
||||
leadership changes, results) within days of occurrence.
|
||||
|
||||
Approach:
|
||||
1. Company -> CIK via SEC's company_tickers.json (downloaded once, cached).
|
||||
2. Recent filings via the submissions API (data.sec.gov).
|
||||
3. Emit each recent filing (within EXA_LOOKBACK_DAYS) as a RawSignal.
|
||||
|
||||
SEC fair-access policy: identify with a UA string; <=10 req/s (we do ~1/account).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .. import config
|
||||
from ..types import RawSignal
|
||||
|
||||
_UA = {"User-Agent": "BSG-Prospecting-Loop chris.olson (christopher.olson@gmail.com)"}
|
||||
_TICKER_CACHE = config.STATE_DIR / "sec_company_tickers.json"
|
||||
_CIK_MAP_CACHE = config.STATE_DIR / "sec_cik_map.json"
|
||||
|
||||
# Filing forms worth surfacing, with a plain-language description.
|
||||
_FORMS = {
|
||||
"8-K": "material event (results, M&A, leadership change, agreements)",
|
||||
"10-K": "annual report (strategy, risk factors, IT/infrastructure spend)",
|
||||
"10-Q": "quarterly report",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> dict:
|
||||
req = urllib.request.Request(url, headers=_UA)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _norm_name(name: str) -> str:
|
||||
n = re.sub(r"[^a-z0-9 ]", "", name.lower())
|
||||
n = re.sub(r"\b(inc|corp|corporation|co|company|companies|holdings?|group|plc|ltd|llc|the)\b", "", n)
|
||||
return re.sub(r"\s+", " ", n).strip()
|
||||
|
||||
|
||||
def _ticker_table() -> list[dict]:
|
||||
"""SEC's full company->CIK table, cached locally (refreshed weekly)."""
|
||||
config.ensure_dirs()
|
||||
if _TICKER_CACHE.exists():
|
||||
age_days = (datetime.now(timezone.utc).timestamp() - _TICKER_CACHE.stat().st_mtime) / 86400
|
||||
if age_days < 7:
|
||||
return json.loads(_TICKER_CACHE.read_text())
|
||||
data = _fetch_json("https://www.sec.gov/files/company_tickers.json")
|
||||
rows = [{"cik": v["cik_str"], "ticker": v["ticker"], "name": v["title"]} for v in data.values()]
|
||||
_TICKER_CACHE.write_text(json.dumps(rows))
|
||||
return rows
|
||||
|
||||
|
||||
def _resolve_cik(account: dict) -> int | None:
|
||||
"""Best-effort account name -> CIK match; result (incl. misses) is cached."""
|
||||
cache = json.loads(_CIK_MAP_CACHE.read_text()) if _CIK_MAP_CACHE.exists() else {}
|
||||
aid = account["accountId"]
|
||||
if aid in cache:
|
||||
return cache[aid] # may be None (known non-public / unmatched)
|
||||
target = _norm_name(account["accountName"])
|
||||
cik = None
|
||||
if target:
|
||||
for row in _ticker_table():
|
||||
if _norm_name(row["name"]) == target:
|
||||
cik = row["cik"]
|
||||
break
|
||||
if cik is None: # relaxed: target is a prefix of the SEC name
|
||||
for row in _ticker_table():
|
||||
if _norm_name(row["name"]).startswith(target) and len(target) >= 5:
|
||||
cik = row["cik"]
|
||||
break
|
||||
cache[aid] = cik
|
||||
_CIK_MAP_CACHE.write_text(json.dumps(cache))
|
||||
return cik
|
||||
|
||||
|
||||
def harvest(account: dict) -> list[RawSignal]:
|
||||
if not config.SEC_ENABLED:
|
||||
return []
|
||||
try:
|
||||
cik = _resolve_cik(account)
|
||||
except Exception as e:
|
||||
print(f" [sec] ticker table unavailable: {str(e)[:80]}")
|
||||
return []
|
||||
if cik is None:
|
||||
return [] # not a public company (or unmatched) — cached, no repeat lookups
|
||||
|
||||
try:
|
||||
subs = _fetch_json(f"https://data.sec.gov/submissions/CIK{cik:010d}.json")
|
||||
except Exception as e:
|
||||
print(f" [sec] submissions fetch failed for {account['accountName']}: {str(e)[:80]}")
|
||||
return []
|
||||
|
||||
recent = subs.get("filings", {}).get("recent", {})
|
||||
forms = recent.get("form", [])
|
||||
dates = recent.get("filingDate", [])
|
||||
accs = recent.get("accessionNumber", [])
|
||||
docs = recent.get("primaryDocument", [])
|
||||
items_col = recent.get("items", [])
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=config.EXA_LOOKBACK_DAYS)
|
||||
|
||||
out: list[RawSignal] = []
|
||||
for i, form in enumerate(forms):
|
||||
if form not in _FORMS or len(out) >= config.SEC_MAX_FILINGS_PER_ACCOUNT:
|
||||
continue
|
||||
try:
|
||||
fdate = datetime.strptime(dates[i], "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if fdate < cutoff:
|
||||
continue
|
||||
acc_no = accs[i].replace("-", "")
|
||||
url = f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc_no}/{docs[i]}"
|
||||
items = items_col[i] if i < len(items_col) else ""
|
||||
content = (
|
||||
f"{account['accountName']} filed a {form} with the SEC on {dates[i]} — "
|
||||
f"{_FORMS[form]}."
|
||||
)
|
||||
if items:
|
||||
content += f" 8-K items reported: {items}."
|
||||
out.append(RawSignal(
|
||||
account_id=account["accountId"],
|
||||
source="sec_filing",
|
||||
signal_type=f"sec_{form.lower().replace('-', '')}",
|
||||
content=content,
|
||||
published_at=f"{dates[i]}T00:00:00Z",
|
||||
url=url,
|
||||
extra={"title": f"SEC {form} filing ({dates[i]})", "form": form, "items": items},
|
||||
))
|
||||
if out:
|
||||
print(f" [sec] {len(out)} recent filing(s) for {account['accountName']} (CIK {cik})")
|
||||
return out
|
||||
@@ -45,7 +45,11 @@ def build_digest(rep_id: str | None = None) -> str:
|
||||
f"{'y' if len(items)==1 else 'ies'}")
|
||||
for b in items:
|
||||
flag = "🚨" if b["priority"] == "priority" else "📊"
|
||||
out.append(f" {flag} [{b['composite_score']}] {b['account_name']} — {b['capability_name']}")
|
||||
tag = " ⬆ EXPANSION" if b.get("expansion_of_owned") else ""
|
||||
rc = b.get("renewal_context")
|
||||
if rc:
|
||||
tag += f" 💰 renewal {rc['renewalDate']}"
|
||||
out.append(f" {flag} [{b['composite_score']}] {b['account_name']} — {b['capability_name']}{tag}")
|
||||
out.append(f" {b['headline']}")
|
||||
out.append(f" brief: {b['id']} | confidence {round(b['confidence_score']*100)}%")
|
||||
out.append("")
|
||||
@@ -55,6 +59,98 @@ def build_digest(rep_id: str | None = None) -> str:
|
||||
def write_digest(rep_id: str | None = None) -> str:
|
||||
config.ensure_dirs()
|
||||
text = build_digest(rep_id)
|
||||
path = config.OUTPUT_DIR / "digest.md"
|
||||
path.write_text(text)
|
||||
# Timestamped file so prior digests are always kept...
|
||||
stamp = now_utc().strftime("%Y-%m-%d_%H%M")
|
||||
suffix = f"_{rep_id}" if rep_id else ""
|
||||
(config.OUTPUT_DIR / f"digest{suffix}_{stamp}.md").write_text(text)
|
||||
# ...plus digest.md as the always-current convenience copy.
|
||||
(config.OUTPUT_DIR / "digest.md").write_text(text)
|
||||
return text
|
||||
|
||||
|
||||
# --- Per-rep HTML digest (forwardable email body) ---
|
||||
|
||||
def _active_briefs(rep: str | None = None) -> list[dict]:
|
||||
out = []
|
||||
for b in store.load_briefs():
|
||||
if b.get("priority") not in ("priority", "standard"):
|
||||
continue
|
||||
if b.get("status") not in (None, "pending"):
|
||||
continue
|
||||
try:
|
||||
if parse_dt(b["expires_at"]) < now_utc():
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
if rep:
|
||||
r = b["assigned_rep"]
|
||||
if rep.lower() not in r.get("name", "").lower() and rep.lower() != r.get("id", "").lower():
|
||||
continue
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">"))
|
||||
|
||||
|
||||
def write_digest_html(rep: str | None = None) -> str:
|
||||
"""Render a clean, self-contained HTML digest (per rep if given).
|
||||
Returns the file path written."""
|
||||
config.ensure_dirs()
|
||||
briefs = sorted(_active_briefs(rep), key=lambda b: b["composite_score"], reverse=True)
|
||||
date = now_utc().strftime("%Y-%m-%d")
|
||||
who = briefs[0]["assigned_rep"]["name"] if (rep and briefs) else (rep or "All reps")
|
||||
|
||||
rows = []
|
||||
for b in briefs:
|
||||
color = "#c0392b" if b["priority"] == "priority" else "#2c3e50"
|
||||
badge = "PRIORITY" if b["priority"] == "priority" else "standard"
|
||||
rc = b.get("renewal_context")
|
||||
renewal_html = ""
|
||||
if rc:
|
||||
renewal_html = (f'<p style="margin:6px 0;padding:6px 10px;background:#fff8e1;'
|
||||
f'border-left:3px solid #f39c12;font-size:13px">💰 <b>Renewal window:</b> '
|
||||
f'{_esc(rc["product"])} renews {_esc(rc["renewalDate"])} — attach this '
|
||||
f'play to the renewal conversation.</p>')
|
||||
tps = "".join(f"<li>{_esc(tp)}</li>" for tp in b.get("talking_points", []))
|
||||
sigs = "".join(
|
||||
f'<li><a href="{_esc(s.get("url", ""))}">{_esc((s.get("content", "") or "")[:110])}…</a> '
|
||||
f'<span style="color:#888">({_esc((s.get("published_at", "") or "")[:10])})</span></li>'
|
||||
for s in b.get("signals", [])
|
||||
)
|
||||
rows.append(f"""
|
||||
<div style="border:1px solid #ddd;border-radius:8px;padding:16px;margin:14px 0;font-family:Arial,Helvetica,sans-serif">
|
||||
<div style="font-size:15px;font-weight:bold;color:{color}">
|
||||
[{badge}] {_esc(b['account_name'])} — {_esc(b['capability_name'])}
|
||||
<span style="float:right;color:#555;font-weight:normal">score {b['composite_score']} · confidence {round(b['confidence_score'] * 100)}%</span>
|
||||
</div>
|
||||
<p style="margin:8px 0"><b>{_esc(b['headline'])}</b></p>
|
||||
{renewal_html}
|
||||
<p style="margin:8px 0;color:#333">{_esc(b['executive_summary'])}</p>
|
||||
<p style="margin:8px 0 4px"><b>Talking points</b> <span style="color:#888">([S#] cites map to sources below)</span>:</p>
|
||||
<ul style="margin:4px 0">{tps}</ul>
|
||||
<p style="margin:8px 0 4px"><b>Suggested opener:</b> <i>{_esc(b.get('outreach_hook', ''))}</i></p>
|
||||
<p style="margin:8px 0 4px"><b>Sources</b>:</p>
|
||||
<ol style="margin:4px 0;font-size:12px">{sigs}</ol>
|
||||
<p style="margin:6px 0 0;font-size:11px;color:#999">brief {b['id']} · expires {_esc(b['expires_at'][:10])}</p>
|
||||
</div>""")
|
||||
|
||||
body = "\n".join(rows) if rows else "<p>No active opportunities in the queue.</p>"
|
||||
html = f"""<!doctype html><html><head><meta charset="utf-8">
|
||||
<title>Intelligence Digest — {_esc(who)} — {date}</title></head>
|
||||
<body style="max-width:760px;margin:24px auto;font-family:Arial,Helvetica,sans-serif;color:#222">
|
||||
<h2 style="border-bottom:2px solid #2c3e50;padding-bottom:8px">
|
||||
📬 Account Intelligence Digest — {_esc(who)} <span style="color:#888;font-weight:normal">{date}</span>
|
||||
</h2>
|
||||
<p style="color:#555">{len(briefs)} active opportunit{"y" if len(briefs) == 1 else "ies"}, ranked by composite score.
|
||||
Every talking point is cited to a real, dated source.</p>
|
||||
{body}
|
||||
</body></html>"""
|
||||
|
||||
digest_dir = config.OUTPUT_DIR / "digests"
|
||||
digest_dir.mkdir(parents=True, exist_ok=True)
|
||||
slug = (rep or "all").lower().replace(" ", "_")
|
||||
path = digest_dir / f"digest_{slug}_{date}.html"
|
||||
path.write_text(html)
|
||||
return str(path)
|
||||
|
||||
@@ -19,7 +19,9 @@ def render_brief_md(brief: dict) -> str:
|
||||
"🎯 OPPORTUNITY BRIEF",
|
||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||||
f"Account: {brief['account_name']}",
|
||||
f"Capability: {brief['capability_name']}",
|
||||
f"Capability: {brief['capability_name']}"
|
||||
+ (" ⬆ EXPANSION (account already owns this product line)"
|
||||
if brief.get("expansion_of_owned") else ""),
|
||||
f"Assigned: {brief['assigned_rep']['name']} ({brief['assigned_rep']['email']})",
|
||||
f"Brief ID: {brief['id']}",
|
||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||||
@@ -30,6 +32,18 @@ def render_brief_md(brief: dict) -> str:
|
||||
f"CONFIDENCE: {pct}% URGENCY: {brief['urgency_level'].upper()} "
|
||||
f"SCORE: {brief['composite_score']} PRIORITY: {brief['priority'].upper()}",
|
||||
"",
|
||||
]
|
||||
rc = brief.get("renewal_context")
|
||||
if rc:
|
||||
months = max(1, round(rc["daysUntil"] / 30))
|
||||
scope = "this product line" if rc.get("sameCapability") else "another product"
|
||||
lines += [
|
||||
f"💰 RENEWAL WINDOW: {rc['product']} renews {rc['renewalDate']} "
|
||||
f"(~{months} mo) — {scope} is up for renewal; attach this play to "
|
||||
f"the renewal conversation. (score boost +{rc['scoreBoost']})",
|
||||
"",
|
||||
]
|
||||
lines += [
|
||||
"EXECUTIVE SUMMARY",
|
||||
f" {brief['executive_summary']}",
|
||||
"",
|
||||
@@ -81,7 +95,10 @@ def render_brief_md(brief: dict) -> str:
|
||||
def deliver(brief: dict) -> None:
|
||||
"""Write the brief to a file; print priority briefs to console (Slack stand-in)."""
|
||||
config.ensure_dirs()
|
||||
path = config.BRIEFS_DIR / f"{brief['priority']}_{brief['account_id']}_{brief['id']}.md"
|
||||
# Filename keyed on the account NAME (legible), not the numeric account id.
|
||||
import re
|
||||
name_slug = re.sub(r"[^a-z0-9]+", "-", brief["account_name"].lower()).strip("-")[:40]
|
||||
path = config.BRIEFS_DIR / f"{brief['priority']}_{name_slug}_{brief['id'][:8]}.md"
|
||||
path.write_text(render_brief_md(brief))
|
||||
emoji = _PRIORITY_EMOJI.get(brief["priority"], "•")
|
||||
if brief["priority"] == "priority":
|
||||
|
||||
126
sales-agent/src/delivery/whitespace.py
Normal file
126
sales-agent/src/delivery/whitespace.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Whitespace / coverage report — territory-planning view.
|
||||
|
||||
Answers: which accounts have surfaced opportunities (and for which capability
|
||||
families), which accounts own products already, and where has the loop never
|
||||
looked (or not looked recently). Writes output/whitespace.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from .. import config, capabilities
|
||||
from ..connectors import crm
|
||||
from ..memory import store
|
||||
from ..types import now_utc, parse_dt
|
||||
|
||||
|
||||
def _cursor() -> dict:
|
||||
p = config.STATE_DIR / "run_cursor.json"
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
|
||||
def build_report() -> str:
|
||||
accounts = crm.load_accounts()
|
||||
briefs = store.load_briefs()
|
||||
cur = _cursor()
|
||||
now = now_utc()
|
||||
|
||||
by_account: dict[str, list[dict]] = defaultdict(list)
|
||||
for b in briefs:
|
||||
by_account[b["account_id"]].append(b)
|
||||
|
||||
covered, uncovered_run, never_run, stale = [], [], [], []
|
||||
for a in accounts:
|
||||
aid = a["accountId"]
|
||||
last = cur.get(aid)
|
||||
age = (now - parse_dt(last)).days if last else None
|
||||
if last is None:
|
||||
never_run.append(a)
|
||||
elif age is not None and age > 14:
|
||||
stale.append((a, age))
|
||||
if by_account.get(aid):
|
||||
covered.append(a)
|
||||
elif last is not None:
|
||||
uncovered_run.append(a)
|
||||
|
||||
cap_counter: Counter = Counter()
|
||||
status_counter: Counter = Counter()
|
||||
for b in briefs:
|
||||
cap_counter[b["capability_name"]] += 1
|
||||
status_counter[b.get("status") or "pending"] += 1
|
||||
|
||||
n = len(accounts)
|
||||
lines = [
|
||||
"═" * 62,
|
||||
f"🗺 WHITESPACE / COVERAGE REPORT — {now.strftime('%Y-%m-%d')}",
|
||||
"═" * 62,
|
||||
"",
|
||||
f"Territory: {n} accounts | {len(capabilities.load_library()['capabilities'])} capabilities",
|
||||
f" Scanned at least once: {n - len(never_run):>4} ({(n - len(never_run)) * 100 // max(n,1)}%)",
|
||||
f" Never scanned: {len(never_run):>4}",
|
||||
f" Scanned but stale (>14d): {len(stale):>4}",
|
||||
f" With surfaced opportunity: {len(covered):>4}",
|
||||
f" Scanned, no opportunity: {len(uncovered_run):>4}",
|
||||
"",
|
||||
"── Opportunities by capability (where the traction is) " + "─" * 6,
|
||||
]
|
||||
for cap, cnt in cap_counter.most_common(15):
|
||||
lines.append(f" {cnt:>3} {cap}")
|
||||
if not cap_counter:
|
||||
lines.append(" (none yet)")
|
||||
|
||||
lines += ["", "── Brief pipeline status " + "─" * 36]
|
||||
for st, cnt in status_counter.most_common():
|
||||
lines.append(f" {cnt:>3} {st}")
|
||||
|
||||
lines += ["", "── Accounts with active opportunities " + "─" * 23]
|
||||
for a in covered:
|
||||
bs = by_account[a["accountId"]]
|
||||
caps = sorted({b["capability_name"] for b in bs})
|
||||
lines.append(f" {a['accountName'][:34]:34} {len(bs)} brief(s): {', '.join(caps)[:70]}")
|
||||
if not covered:
|
||||
lines.append(" (none yet)")
|
||||
|
||||
lines += ["", f"── Never scanned ({len(never_run)}) — run `loop --limit N` to work the backlog " + "─" * 3]
|
||||
for a in never_run[:25]:
|
||||
lines.append(f" {a['accountTier']:10} {a['accountName']}")
|
||||
if len(never_run) > 25:
|
||||
lines.append(f" ... and {len(never_run) - 25} more")
|
||||
|
||||
if stale:
|
||||
lines += ["", f"── Stale (>14 days since last scan) ({len(stale)}) " + "─" * 16]
|
||||
for a, age in sorted(stale, key=lambda x: -x[1])[:15]:
|
||||
lines.append(f" {age:>3}d {a['accountTier']:10} {a['accountName']}")
|
||||
|
||||
# Entitlement-aware whitespace: which capability families are most owned,
|
||||
# and how many accounts have zero footprint in each pillar.
|
||||
owned_counter: Counter = Counter()
|
||||
with_ent = 0
|
||||
for a in accounts:
|
||||
caps = a.get("ownedCapabilityIds", [])
|
||||
if caps:
|
||||
with_ent += 1
|
||||
for c in caps:
|
||||
owned_counter[c] += 1
|
||||
lines += ["", "── Installed base (from entitlement import) " + "─" * 17,
|
||||
f" accounts with entitlement data: {with_ent}/{n}"]
|
||||
for cap_id, cnt in owned_counter.most_common(12):
|
||||
cap = capabilities.capability_by_id(cap_id)
|
||||
lines.append(f" {cnt:>3} own {cap['name'] if cap else cap_id}")
|
||||
if owned_counter:
|
||||
pillars = {"cap_vmw": "VMware", "cap_mf": "Mainframe", "cap_es": "Enterprise SW", "cap_iam": "IAM"}
|
||||
lines += ["", " Whitespace by pillar (accounts owning NOTHING in that pillar):"]
|
||||
for prefix, label in pillars.items():
|
||||
without = sum(1 for a in accounts
|
||||
if not any(c.startswith(prefix) for c in a.get("ownedCapabilityIds", [])))
|
||||
lines.append(f" {label:14} {without:>3} of {n} accounts have zero footprint")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_report() -> str:
|
||||
config.ensure_dirs()
|
||||
text = build_report()
|
||||
(config.OUTPUT_DIR / "whitespace.md").write_text(text)
|
||||
return text
|
||||
@@ -69,7 +69,7 @@ def _post(url: str, payload: dict, headers: dict, provider: str) -> dict:
|
||||
headers={"content-type": "application/json", **headers}, method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8")[:400] if hasattr(e, "read") else ""
|
||||
@@ -109,7 +109,15 @@ def _call_openai(system: str, user: str, max_tokens: int) -> dict:
|
||||
if config.OPENAI_PRESENCE_PENALTY:
|
||||
payload["presence_penalty"] = config.OPENAI_PRESENCE_PENALTY
|
||||
if config.OPENAI_JSON_MODE:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
payload["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "generic_json_object",
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
headers = {"authorization": f"Bearer {config.OPENAI_API_KEY}"}
|
||||
body = _post(url, payload, headers, "OpenAI-compatible")
|
||||
choices = body.get("choices", [])
|
||||
@@ -153,19 +161,72 @@ def _call_gemini(system: str, user: str, max_tokens: int) -> dict:
|
||||
return _extract_json(text)
|
||||
|
||||
|
||||
_keychain_token_cache: dict = {}
|
||||
|
||||
|
||||
def _anthropic_oauth_token() -> str:
|
||||
"""Resolve an OAuth access token: env var first, then the Claude Code
|
||||
credential stored in the macOS keychain (kept fresh by Claude Code)."""
|
||||
if config.ANTHROPIC_OAUTH_TOKEN:
|
||||
return config.ANTHROPIC_OAUTH_TOKEN
|
||||
if not config.ANTHROPIC_USE_KEYCHAIN:
|
||||
return ""
|
||||
# Cache for 60s to avoid a keychain hit on every call.
|
||||
now = time.time()
|
||||
if _keychain_token_cache.get("expires", 0) > now:
|
||||
return _keychain_token_cache["token"]
|
||||
try:
|
||||
import subprocess
|
||||
raw = subprocess.run(
|
||||
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
).stdout.strip()
|
||||
cred = json.loads(raw).get("claudeAiOauth", {})
|
||||
token = cred.get("accessToken", "")
|
||||
exp_ms = cred.get("expiresAt", 0)
|
||||
if token and exp_ms and exp_ms / 1000 < now:
|
||||
raise LLMNotConfigured(
|
||||
"The Claude Code OAuth token in the keychain is EXPIRED "
|
||||
f"(expired {time.strftime('%Y-%m-%d %H:%M', time.localtime(exp_ms/1000))}). "
|
||||
"Run any command in the Claude Code CLI (e.g. `claude -p hi`) to refresh it, "
|
||||
"or set ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY in .env."
|
||||
)
|
||||
if token:
|
||||
_keychain_token_cache.update({"token": token, "expires": now + 60})
|
||||
return token
|
||||
except LLMNotConfigured:
|
||||
raise
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _call_anthropic(system: str, user: str, max_tokens: int) -> dict:
|
||||
payload = {
|
||||
"model": config.LLM_MODEL,
|
||||
"max_tokens": max_tokens,
|
||||
"system": system,
|
||||
"messages": [{"role": "user", "content": user}],
|
||||
# Reasoning-effort control (Sonnet 5 / Opus 4.6+). "low" keeps the
|
||||
# relevance/synthesis calls fast and cheap. NOTE: Sonnet 5 rejects
|
||||
# non-default temperature/top_p — do not add sampling params here.
|
||||
"output_config": {"effort": config.ANTHROPIC_EFFORT},
|
||||
}
|
||||
headers = {
|
||||
"x-api-key": config.ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
token = _anthropic_oauth_token()
|
||||
if token:
|
||||
# OAuth bearer auth (subscription) + required beta header.
|
||||
headers["authorization"] = f"Bearer {token}"
|
||||
headers["anthropic-beta"] = "oauth-2025-04-20"
|
||||
elif config.ANTHROPIC_API_KEY:
|
||||
headers["x-api-key"] = config.ANTHROPIC_API_KEY
|
||||
else:
|
||||
raise LLMNotConfigured(
|
||||
"Anthropic provider selected but no OAuth token (env/keychain) or API key found."
|
||||
)
|
||||
body = _post("https://api.anthropic.com/v1/messages", payload, headers, "Anthropic")
|
||||
text = "".join(b.get("text", "") for b in body.get("content", []))
|
||||
text = "".join(
|
||||
b.get("text", "") for b in body.get("content", []) if b.get("type") == "text"
|
||||
)
|
||||
return _extract_json(text)
|
||||
|
||||
|
||||
|
||||
66
sales-agent/src/memory/entitlements.py
Normal file
66
sales-agent/src/memory/entitlements.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Local entitlement lookups (renewals, ownership) — no LLM, no network.
|
||||
|
||||
Reads data/entitlements.json (gitignored, produced by scripts/import_entitlements.py).
|
||||
Renewal proximity is a commercial trigger: an account negotiating a renewal within
|
||||
the window is already in a buying conversation — the best moment to attach an
|
||||
expansion or cross-sell motion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load() -> dict:
|
||||
p = config.DATA_DIR / "entitlements.json"
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
|
||||
def _days_until(date_str: str) -> int | None:
|
||||
try:
|
||||
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return (d - datetime.now(timezone.utc)).days
|
||||
|
||||
|
||||
def renewal_context(account_id: str, capability_id: str) -> dict | None:
|
||||
"""Soonest FUTURE renewal within the window, preferring products mapped to
|
||||
the brief's capability; falls back to the account's soonest renewal of any
|
||||
product (an account in renewal talks is engageable for cross-sell too).
|
||||
|
||||
Returns {product, renewalDate, daysUntil, sameCapability} or None.
|
||||
"""
|
||||
ent = _load().get(str(account_id))
|
||||
if not ent:
|
||||
return None
|
||||
best_same, best_any = None, None
|
||||
for p in ent.get("products", []):
|
||||
days = _days_until(p.get("nextRenewal", ""))
|
||||
if days is None or days < 0 or days > config.RENEWAL_WINDOW_DAYS:
|
||||
continue
|
||||
cand = {"product": p["product"], "renewalDate": p["nextRenewal"], "daysUntil": days}
|
||||
if capability_id in (p.get("capabilities") or []):
|
||||
if best_same is None or days < best_same["daysUntil"]:
|
||||
best_same = cand
|
||||
if best_any is None or days < best_any["daysUntil"]:
|
||||
best_any = cand
|
||||
if best_same:
|
||||
return {**best_same, "sameCapability": True}
|
||||
if best_any:
|
||||
return {**best_any, "sameCapability": False}
|
||||
return None
|
||||
|
||||
|
||||
def renewal_boost(ctx: dict | None) -> float:
|
||||
"""Score bonus: scales linearly as the renewal approaches (closer = larger).
|
||||
Same-capability renewals get the full weight; account-level renewals half."""
|
||||
if not ctx:
|
||||
return 0.0
|
||||
closeness = 1.0 - (ctx["daysUntil"] / max(config.RENEWAL_WINDOW_DAYS, 1))
|
||||
weight = config.RENEWAL_BOOST_MAX if ctx["sameCapability"] else config.RENEWAL_BOOST_MAX / 2
|
||||
return round(weight * closeness, 4)
|
||||
@@ -1,11 +1,14 @@
|
||||
"""The account cycle — the closed loop, run per account (design section 7.3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import config, llm
|
||||
from .agents import harvester, normalizer, relevance, synthesizer, scorer
|
||||
from .agents import harvester, normalizer, relevance, synthesizer, scorer, quality_gate
|
||||
from .delivery import render
|
||||
from .connectors import crm, exa
|
||||
from .memory import store
|
||||
from .types import now_utc, iso, parse_dt
|
||||
|
||||
|
||||
def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
|
||||
@@ -19,14 +22,25 @@ def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
|
||||
if verbose:
|
||||
print(f" harvested {len(raw)} raw signal(s)")
|
||||
|
||||
# 2. Normalize + dedup
|
||||
# 2. Normalize + dedup + quality gate (drop PR fluff before spending LLM calls)
|
||||
signals = normalizer.normalize_and_dedup(raw, account, mem)
|
||||
if verbose:
|
||||
print(f" {len(signals)} new signal(s) after dedup")
|
||||
kept = quality_gate.filter_quality(signals, verbose=verbose)
|
||||
kept_ids = {s.id for s in kept}
|
||||
gated_out = [s for s in signals if s.id not in kept_ids]
|
||||
signals = kept
|
||||
# Noise signals are consumed (marked seen) so they aren't re-fetched forever.
|
||||
if gated_out:
|
||||
store.record_signals(mem, gated_out)
|
||||
if not signals:
|
||||
store.save_memory(mem)
|
||||
_mark_run(account["accountId"])
|
||||
return []
|
||||
|
||||
# Inject rolling narrative so the LLM connects new signals to prior context.
|
||||
account["_narrative"] = mem.get("accountNarrative", "")
|
||||
|
||||
# 3 + 4. Relevance filter + synthesis (LLM). On LLM failure we skip the account
|
||||
# and do NOT consume the signals, so they are retried on the next run (rather
|
||||
# than emitting low-quality output). 429s are waited out inside the LLM layer.
|
||||
@@ -37,8 +51,9 @@ def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
|
||||
if not relevant:
|
||||
store.record_signals(mem, signals) # evaluated, found irrelevant -> mark seen
|
||||
store.save_memory(mem)
|
||||
_mark_run(account["accountId"])
|
||||
return []
|
||||
briefs = synthesizer.synthesize(relevant, account)
|
||||
briefs, narrative = synthesizer.synthesize(relevant, account)
|
||||
except llm.LLMError as e:
|
||||
print(f" ! LLM unavailable for {account['accountName']}: {str(e)[:160]}")
|
||||
print(" skipping account — signals NOT consumed, will retry next run.")
|
||||
@@ -46,6 +61,8 @@ def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
|
||||
|
||||
# Signals fully processed by the LLM -> mark seen so we don't re-evaluate them.
|
||||
store.record_signals(mem, signals)
|
||||
if narrative:
|
||||
mem["accountNarrative"] = narrative
|
||||
|
||||
# 5. Score + prioritize
|
||||
briefs = scorer.score_and_prioritize(briefs, account)
|
||||
@@ -69,18 +86,61 @@ def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
|
||||
continue
|
||||
|
||||
bd = b.to_dict()
|
||||
# Local entitlement check (no LLM): capability already owned per the
|
||||
# ACL import -> deliver as an EXPANSION play, not a net-new pitch.
|
||||
if b.capability_id in set(account.get("ownedCapabilityIds", [])):
|
||||
bd["expansion_of_owned"] = True
|
||||
if verbose:
|
||||
print(f" note: {b.capability_name} already owned -> tagged EXPANSION")
|
||||
store.save_brief(bd)
|
||||
store.record_brief(mem, bd)
|
||||
render.deliver(bd) # 6. Deliver
|
||||
crm.log_activity(aid, bd) # + CRM write-back stub
|
||||
delivered.append(bd)
|
||||
|
||||
# 7. Update memory
|
||||
# 7. Update memory + run cursor
|
||||
store.save_memory(mem)
|
||||
_mark_run(account["accountId"])
|
||||
return delivered
|
||||
|
||||
|
||||
def run_all(tier: str | None = None, account_id: str | None = None) -> list[dict]:
|
||||
# --- Run cursor: which accounts were covered when (drives --limit batching) ---
|
||||
|
||||
def _cursor_path():
|
||||
return config.STATE_DIR / "run_cursor.json"
|
||||
|
||||
|
||||
def _load_cursor() -> dict:
|
||||
p = _cursor_path()
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
|
||||
def _mark_run(account_id: str) -> None:
|
||||
config.ensure_dirs()
|
||||
cur = _load_cursor()
|
||||
cur[account_id] = iso(now_utc())
|
||||
_cursor_path().write_text(json.dumps(cur, indent=2))
|
||||
|
||||
|
||||
def _order_for_run(accounts: list[dict]) -> list[dict]:
|
||||
"""Strategic tier first, then least-recently-run (never-run first)."""
|
||||
cur = _load_cursor()
|
||||
tier_rank = {"strategic": 0, "enterprise": 1, "growth": 2}
|
||||
|
||||
def key(a):
|
||||
last = cur.get(a["accountId"])
|
||||
ts = parse_dt(last).timestamp() if last else 0.0
|
||||
return (tier_rank.get(a["accountTier"], 3), ts)
|
||||
|
||||
return sorted(accounts, key=key)
|
||||
|
||||
|
||||
def run_all(
|
||||
tier: str | None = None,
|
||||
account_id: str | None = None,
|
||||
limit: int | None = None,
|
||||
rep: str | None = None,
|
||||
) -> list[dict]:
|
||||
config.ensure_dirs()
|
||||
if not config.LLM_CONFIGURED:
|
||||
print("ERROR: no LLM provider configured. Set OPENAI_API_KEY (+ OPENAI_BASE_URL/"
|
||||
@@ -88,10 +148,21 @@ def run_all(tier: str | None = None, account_id: str | None = None) -> list[dict
|
||||
print("Refusing to run without a reasoning model (no mock fallback).")
|
||||
return []
|
||||
accounts = crm.load_accounts(tier=tier, account_id=account_id)
|
||||
if rep:
|
||||
accounts = [a for a in accounts
|
||||
if rep.lower() in a["assignedRep"]["name"].lower()
|
||||
or rep.lower() == a["assignedRep"]["id"].lower()]
|
||||
if not accounts:
|
||||
print("No matching accounts.")
|
||||
return []
|
||||
print(f"Running prospecting loop over {len(accounts)} account(s) — LLM: {config.llm_label()}")
|
||||
# Batch scheduling: strategic first, then longest-stale. With --limit N a
|
||||
# nightly run quietly walks the whole territory over successive runs.
|
||||
accounts = _order_for_run(accounts)
|
||||
total = len(accounts)
|
||||
if limit and limit > 0:
|
||||
accounts = accounts[:limit]
|
||||
print(f"Running prospecting loop over {len(accounts)}/{total} account(s) "
|
||||
f"(strategic + least-recently-run first) — LLM: {config.llm_label()}")
|
||||
if config.EXA_ENABLED:
|
||||
st = exa.usage_status()
|
||||
scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web"
|
||||
|
||||
@@ -104,6 +104,7 @@ class OpportunityBrief:
|
||||
created_at: str
|
||||
expires_at: str
|
||||
status: str = "pending" # pending | accepted | rejected | snoozed | won | lost
|
||||
renewal_context: Optional[dict] = None # set by scorer when a renewal is in window
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
Reference in New Issue
Block a user