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.
|
# Copy to .env and fill in. The loop runs WITHOUT a key using a mock reasoner.
|
||||||
|
|
||||||
# --- LLM provider ---
|
# --- LLM provider ---
|
||||||
# LLM_PROVIDER: openai | gemini | anthropic (auto-detected from whichever key is
|
# LLM_PROVIDER: anthropic | openai | gemini (auto-detected if blank).
|
||||||
# set if blank; OpenAI-compatible preferred). Provider w/ a missing key => mock.
|
LLM_PROVIDER=anthropic
|
||||||
LLM_PROVIDER=openai
|
|
||||||
|
# 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,
|
# OpenAI-compatible endpoint — works with OpenAI, OpenRouter, Together, Groq,
|
||||||
# vLLM, Ollama, LM Studio, etc. Just point these three at any /chat/completions API:
|
# 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:
|
# Other providers:
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
GEMINI_MODEL=gemini-2.5-flash
|
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)
|
# Scoring thresholds (design Phase 5)
|
||||||
PRIORITY_THRESHOLD=0.72
|
PRIORITY_THRESHOLD=0.72
|
||||||
|
|||||||
5
sales-agent/.gitignore
vendored
5
sales-agent/.gitignore
vendored
@@ -2,3 +2,8 @@ output/
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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 import config, pipeline
|
||||||
from src.connectors import crm, exa
|
from src.connectors import crm, exa
|
||||||
from src.delivery import digest as digest_mod
|
from src.delivery import digest as digest_mod
|
||||||
|
from src.delivery import whitespace as whitespace_mod
|
||||||
from src.memory import store, feedback
|
from src.memory import store, feedback
|
||||||
|
|
||||||
|
|
||||||
@@ -25,13 +26,57 @@ def cmd_loop(args):
|
|||||||
config.EXA_DOMAIN_FILTER = False # per-run override
|
config.EXA_DOMAIN_FILTER = False # per-run override
|
||||||
if args.domain_filter:
|
if args.domain_filter:
|
||||||
config.EXA_DOMAIN_FILTER = True
|
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("\n--- digest preview ---")
|
||||||
print(digest_mod.write_digest())
|
print(digest_mod.write_digest())
|
||||||
|
|
||||||
|
|
||||||
def cmd_digest(args):
|
def cmd_digest(args):
|
||||||
print(digest_mod.write_digest(rep_id=args.rep))
|
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):
|
def cmd_feedback(args):
|
||||||
@@ -52,8 +97,10 @@ def cmd_recalibrate(args):
|
|||||||
|
|
||||||
def cmd_accounts(args):
|
def cmd_accounts(args):
|
||||||
for a in crm.load_accounts():
|
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} "
|
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):
|
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 = sub.add_parser("loop", help="Run the harvest->deliver loop")
|
||||||
p_loop.add_argument("--account", help="single account id")
|
p_loop.add_argument("--account", help="single account id")
|
||||||
p_loop.add_argument("--tier", choices=["strategic", "enterprise", "growth"])
|
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",
|
p_loop.add_argument("--no-domain-filter", action="store_true",
|
||||||
help="broad-web news about the account (default behavior)")
|
help="broad-web news about the account (default behavior)")
|
||||||
p_loop.add_argument("--domain-filter", action="store_true",
|
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_loop.set_defaults(func=cmd_loop)
|
||||||
|
|
||||||
p_dig = sub.add_parser("digest", help="Print the daily intelligence digest")
|
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)
|
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 = sub.add_parser("feedback", help="Record rep feedback on a brief")
|
||||||
p_fb.add_argument("--brief", required=True)
|
p_fb.add_argument("--brief", required=True)
|
||||||
p_fb.add_argument("--action", 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."""
|
"""Phase 1: Signal Harvesting. Fans out across all source connectors per account."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ..connectors import linkedin, news, jobs
|
from ..connectors import linkedin, news, jobs, sec_edgar
|
||||||
from ..types import RawSignal
|
from ..types import RawSignal
|
||||||
|
|
||||||
|
|
||||||
def run_harvesters(account: dict) -> list[RawSignal]:
|
def run_harvesters(account: dict) -> list[RawSignal]:
|
||||||
"""Equivalent of Promise.allSettled across source harvesters."""
|
"""Equivalent of Promise.allSettled across source harvesters."""
|
||||||
raw: list[RawSignal] = []
|
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:
|
try:
|
||||||
raw.extend(harvester(account))
|
raw.extend(harvester(account))
|
||||||
except Exception as e: # one bad source shouldn't kill the run
|
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}
|
Company: {account_name}
|
||||||
Current Products: {current_products}
|
Current Products: {current_products}
|
||||||
Account Tier: {account_tier}
|
Account Tier: {account_tier}
|
||||||
|
Account Narrative (rolling context from prior runs — use it to connect new
|
||||||
|
signals to known strategic direction): {narrative}
|
||||||
|
|
||||||
CANDIDATE CAPABILITIES
|
CANDIDATE CAPABILITIES
|
||||||
{capability_summary}
|
{capability_summary}
|
||||||
@@ -92,6 +94,7 @@ def _evaluate_batch(prepared, account) -> list[tuple[NormalizedSignal, Relevance
|
|||||||
account_name=account["accountName"],
|
account_name=account["accountName"],
|
||||||
current_products=", ".join(account.get("currentProducts", [])) or "none",
|
current_products=", ".join(account.get("currentProducts", [])) or "none",
|
||||||
account_tier=account["accountTier"],
|
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())),
|
capability_summary=capabilities.library_summary_for_prompt(list(cap_by_id.values())),
|
||||||
signals_block=_signals_block(prepared),
|
signals_block=_signals_block(prepared),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..types import OpportunityBrief, now_utc, parse_dt
|
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}
|
_TIER = {"strategic": 1.0, "enterprise": 0.66, "growth": 0.33}
|
||||||
_SENIORITY = {"c-suite": 1.0, "evp": 1.0, "svp": 0.85, "vp": 0.7,
|
_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_timing * 0.10
|
||||||
+ s_feedback * 0.05
|
+ 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)
|
b.composite_score = round(composite, 3)
|
||||||
# Hard gate: the model's own low confidence keeps a brief out of delivery.
|
# Hard gate: the model's own low confidence keeps a brief out of delivery.
|
||||||
if s_confidence < config.CONFIDENCE_FLOOR:
|
if s_confidence < config.CONFIDENCE_FLOOR:
|
||||||
|
|||||||
@@ -22,22 +22,32 @@ signal. Return JSON only."""
|
|||||||
# One call per account: every capability opportunity is synthesized together.
|
# One call per account: every capability opportunity is synthesized together.
|
||||||
BATCH_USER_TMPL = """ACCOUNT
|
BATCH_USER_TMPL = """ACCOUNT
|
||||||
{account_name} (tier: {account_tier}); current products: {current_products}
|
{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
|
Produce ONE brief for EACH target capability below, using only that capability's
|
||||||
validated signals.
|
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}
|
{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):
|
Return ONLY this JSON (one object per capabilityId):
|
||||||
{{"briefs": [
|
{{"briefs": [
|
||||||
{{"capabilityId": str,
|
{{"capabilityId": str,
|
||||||
"headline": str (one sentence),
|
"headline": str (one sentence),
|
||||||
"executiveSummary": str (2-3 sentences for the rep),
|
"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),
|
"outreachHook": str (one opener for email/LinkedIn DM),
|
||||||
"urgencyLevel": "high|medium|low",
|
"urgencyLevel": "high|medium|low",
|
||||||
"confidenceScore": float 0..1}}
|
"confidenceScore": float 0..1}}
|
||||||
]}}"""
|
],
|
||||||
|
"accountNarrative": str}}"""
|
||||||
|
|
||||||
|
|
||||||
def _signals_block(items: list[tuple[NormalizedSignal, RelevanceResult]]) -> 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_name=account["accountName"],
|
||||||
account_tier=account["accountTier"],
|
account_tier=account["accountTier"],
|
||||||
current_products=", ".join(account.get("currentProducts", [])) or "none",
|
current_products=", ".join(account.get("currentProducts", [])) or "none",
|
||||||
|
narrative=account.get("_narrative") or "(none yet)",
|
||||||
capability_blocks="\n\n".join(blocks),
|
capability_blocks="\n\n".join(blocks),
|
||||||
)
|
)
|
||||||
data = llm.call_json(SYSTEM, user, max_tokens=4096, require_keys={"briefs"})
|
data = llm.call_json(SYSTEM, user, max_tokens=6000, require_keys={"briefs"})
|
||||||
return {
|
bodies = {
|
||||||
b["capabilityId"]: b
|
b["capabilityId"]: b
|
||||||
for b in data.get("briefs", [])
|
for b in data.get("briefs", [])
|
||||||
if isinstance(b, dict) and b.get("capabilityId")
|
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(
|
def synthesize(
|
||||||
relevant: list[tuple[NormalizedSignal, RelevanceResult]], account: dict
|
relevant: list[tuple[NormalizedSignal, RelevanceResult]], account: dict
|
||||||
) -> list[OpportunityBrief]:
|
) -> tuple[list[OpportunityBrief], str]:
|
||||||
|
"""Returns (briefs, updated_account_narrative)."""
|
||||||
# Group by capability id.
|
# Group by capability id.
|
||||||
by_cap: dict[str, list[tuple[NormalizedSignal, RelevanceResult]]] = {}
|
by_cap: dict[str, list[tuple[NormalizedSignal, RelevanceResult]]] = {}
|
||||||
for s, r in relevant:
|
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
|
items.sort(key=lambda x: order.get(x[1].signal_strength, 3)) # strongest first
|
||||||
groups.append((cap_id, cap, items))
|
groups.append((cap_id, cap, items))
|
||||||
if not groups:
|
if not groups:
|
||||||
return []
|
return [], ""
|
||||||
|
|
||||||
# One LLM call for all of this account's briefs (raises on failure -> caller skips).
|
# 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] = []
|
briefs: list[OpportunityBrief] = []
|
||||||
for cap_id, cap, items in groups:
|
for cap_id, cap, items in groups:
|
||||||
@@ -118,6 +156,11 @@ def synthesize(
|
|||||||
if not body:
|
if not body:
|
||||||
continue # model didn't return a brief for this capability — skip, don't fabricate
|
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()
|
created = now_utc()
|
||||||
ideal = items[0][1].ideal_contact
|
ideal = items[0][1].ideal_contact
|
||||||
brief = OpportunityBrief(
|
brief = OpportunityBrief(
|
||||||
@@ -137,7 +180,7 @@ def synthesize(
|
|||||||
}
|
}
|
||||||
for s, r in items
|
for s, r in items
|
||||||
],
|
],
|
||||||
talking_points=body.get("talkingPoints", []),
|
talking_points=talking_points,
|
||||||
outreach_hook=body.get("outreachHook", ""),
|
outreach_hook=body.get("outreachHook", ""),
|
||||||
target_contacts=_target_contacts(account, ideal),
|
target_contacts=_target_contacts(account, ideal),
|
||||||
urgency_level=body.get("urgencyLevel", "medium"),
|
urgency_level=body.get("urgencyLevel", "medium"),
|
||||||
@@ -149,4 +192,4 @@ def synthesize(
|
|||||||
expires_at=iso(created + timedelta(days=config.BRIEF_TTL_DAYS)),
|
expires_at=iso(created + timedelta(days=config.BRIEF_TTL_DAYS)),
|
||||||
)
|
)
|
||||||
briefs.append(brief)
|
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"))
|
LLM_RATELIMIT_MAX_RETRIES = int(os.environ.get("LLM_RATELIMIT_MAX_RETRIES", "24"))
|
||||||
|
|
||||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
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_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip()
|
||||||
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||||
|
|
||||||
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
|
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
|
||||||
if not _provider:
|
if not _provider:
|
||||||
if OPENAI_API_KEY:
|
if ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY:
|
||||||
|
_provider = "anthropic"
|
||||||
|
elif OPENAI_API_KEY:
|
||||||
_provider = "openai"
|
_provider = "openai"
|
||||||
elif GEMINI_API_KEY:
|
elif GEMINI_API_KEY:
|
||||||
_provider = "gemini"
|
_provider = "gemini"
|
||||||
elif ANTHROPIC_API_KEY:
|
|
||||||
_provider = "anthropic"
|
|
||||||
else:
|
else:
|
||||||
_provider = "none"
|
_provider = "none"
|
||||||
# Selecting a provider whose key is missing => no usable provider.
|
# 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:
|
if _provider == "openai" and not OPENAI_API_KEY:
|
||||||
_provider = "none"
|
_provider = "none"
|
||||||
elif _provider == "gemini" and not GEMINI_API_KEY:
|
elif _provider == "gemini" and not GEMINI_API_KEY:
|
||||||
_provider = "none"
|
_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"
|
_provider = "none"
|
||||||
|
|
||||||
LLM_PROVIDER = _provider
|
LLM_PROVIDER = _provider
|
||||||
@@ -95,7 +105,8 @@ def llm_label() -> str:
|
|||||||
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
|
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
|
||||||
if LLM_PROVIDER == "gemini":
|
if LLM_PROVIDER == "gemini":
|
||||||
return f"Gemini ({GEMINI_MODEL})"
|
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) ---
|
# --- Scoring thresholds (Phase 5 of the design) ---
|
||||||
PRIORITY_THRESHOLD = float(os.environ.get("PRIORITY_THRESHOLD", "0.72"))
|
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.
|
# the model itself doubts out of the rep's queue.
|
||||||
CONFIDENCE_FLOOR = float(os.environ.get("CONFIDENCE_FLOOR", "0.35"))
|
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.ai news/web harvester ---
|
||||||
EXA_API_KEY = os.environ.get("EXA_API_KEY", "").strip()
|
EXA_API_KEY = os.environ.get("EXA_API_KEY", "").strip()
|
||||||
# Enabled only when a key is present AND not explicitly turned off.
|
# 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()
|
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 ---
|
# --- Dedup / suppression windows ---
|
||||||
SEMANTIC_DEDUP_DAYS = 90
|
SEMANTIC_DEDUP_DAYS = 90
|
||||||
SAME_TYPE_SUPPRESS_DAYS = 14
|
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'}")
|
f"{'y' if len(items)==1 else 'ies'}")
|
||||||
for b in items:
|
for b in items:
|
||||||
flag = "🚨" if b["priority"] == "priority" else "📊"
|
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" {b['headline']}")
|
||||||
out.append(f" brief: {b['id']} | confidence {round(b['confidence_score']*100)}%")
|
out.append(f" brief: {b['id']} | confidence {round(b['confidence_score']*100)}%")
|
||||||
out.append("")
|
out.append("")
|
||||||
@@ -55,6 +59,98 @@ def build_digest(rep_id: str | None = None) -> str:
|
|||||||
def write_digest(rep_id: str | None = None) -> str:
|
def write_digest(rep_id: str | None = None) -> str:
|
||||||
config.ensure_dirs()
|
config.ensure_dirs()
|
||||||
text = build_digest(rep_id)
|
text = build_digest(rep_id)
|
||||||
path = config.OUTPUT_DIR / "digest.md"
|
# Timestamped file so prior digests are always kept...
|
||||||
path.write_text(text)
|
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
|
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",
|
"🎯 OPPORTUNITY BRIEF",
|
||||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||||||
f"Account: {brief['account_name']}",
|
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"Assigned: {brief['assigned_rep']['name']} ({brief['assigned_rep']['email']})",
|
||||||
f"Brief ID: {brief['id']}",
|
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"CONFIDENCE: {pct}% URGENCY: {brief['urgency_level'].upper()} "
|
||||||
f"SCORE: {brief['composite_score']} PRIORITY: {brief['priority'].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",
|
"EXECUTIVE SUMMARY",
|
||||||
f" {brief['executive_summary']}",
|
f" {brief['executive_summary']}",
|
||||||
"",
|
"",
|
||||||
@@ -81,7 +95,10 @@ def render_brief_md(brief: dict) -> str:
|
|||||||
def deliver(brief: dict) -> None:
|
def deliver(brief: dict) -> None:
|
||||||
"""Write the brief to a file; print priority briefs to console (Slack stand-in)."""
|
"""Write the brief to a file; print priority briefs to console (Slack stand-in)."""
|
||||||
config.ensure_dirs()
|
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))
|
path.write_text(render_brief_md(brief))
|
||||||
emoji = _PRIORITY_EMOJI.get(brief["priority"], "•")
|
emoji = _PRIORITY_EMOJI.get(brief["priority"], "•")
|
||||||
if brief["priority"] == "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",
|
headers={"content-type": "application/json", **headers}, method="POST",
|
||||||
)
|
)
|
||||||
try:
|
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"))
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
detail = e.read().decode("utf-8")[:400] if hasattr(e, "read") else ""
|
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:
|
if config.OPENAI_PRESENCE_PENALTY:
|
||||||
payload["presence_penalty"] = config.OPENAI_PRESENCE_PENALTY
|
payload["presence_penalty"] = config.OPENAI_PRESENCE_PENALTY
|
||||||
if config.OPENAI_JSON_MODE:
|
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}"}
|
headers = {"authorization": f"Bearer {config.OPENAI_API_KEY}"}
|
||||||
body = _post(url, payload, headers, "OpenAI-compatible")
|
body = _post(url, payload, headers, "OpenAI-compatible")
|
||||||
choices = body.get("choices", [])
|
choices = body.get("choices", [])
|
||||||
@@ -153,19 +161,72 @@ def _call_gemini(system: str, user: str, max_tokens: int) -> dict:
|
|||||||
return _extract_json(text)
|
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:
|
def _call_anthropic(system: str, user: str, max_tokens: int) -> dict:
|
||||||
payload = {
|
payload = {
|
||||||
"model": config.LLM_MODEL,
|
"model": config.LLM_MODEL,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"system": system,
|
"system": system,
|
||||||
"messages": [{"role": "user", "content": user}],
|
"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 = {
|
headers = {"anthropic-version": "2023-06-01"}
|
||||||
"x-api-key": config.ANTHROPIC_API_KEY,
|
token = _anthropic_oauth_token()
|
||||||
"anthropic-version": "2023-06-01",
|
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")
|
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)
|
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)."""
|
"""The account cycle — the closed loop, run per account (design section 7.3)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from . import config, llm
|
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 .delivery import render
|
||||||
from .connectors import crm, exa
|
from .connectors import crm, exa
|
||||||
from .memory import store
|
from .memory import store
|
||||||
|
from .types import now_utc, iso, parse_dt
|
||||||
|
|
||||||
|
|
||||||
def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
|
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:
|
if verbose:
|
||||||
print(f" harvested {len(raw)} raw signal(s)")
|
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)
|
signals = normalizer.normalize_and_dedup(raw, account, mem)
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" {len(signals)} new signal(s) after dedup")
|
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:
|
if not signals:
|
||||||
store.save_memory(mem)
|
store.save_memory(mem)
|
||||||
|
_mark_run(account["accountId"])
|
||||||
return []
|
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
|
# 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
|
# 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.
|
# 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:
|
if not relevant:
|
||||||
store.record_signals(mem, signals) # evaluated, found irrelevant -> mark seen
|
store.record_signals(mem, signals) # evaluated, found irrelevant -> mark seen
|
||||||
store.save_memory(mem)
|
store.save_memory(mem)
|
||||||
|
_mark_run(account["accountId"])
|
||||||
return []
|
return []
|
||||||
briefs = synthesizer.synthesize(relevant, account)
|
briefs, narrative = synthesizer.synthesize(relevant, account)
|
||||||
except llm.LLMError as e:
|
except llm.LLMError as e:
|
||||||
print(f" ! LLM unavailable for {account['accountName']}: {str(e)[:160]}")
|
print(f" ! LLM unavailable for {account['accountName']}: {str(e)[:160]}")
|
||||||
print(" skipping account — signals NOT consumed, will retry next run.")
|
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.
|
# Signals fully processed by the LLM -> mark seen so we don't re-evaluate them.
|
||||||
store.record_signals(mem, signals)
|
store.record_signals(mem, signals)
|
||||||
|
if narrative:
|
||||||
|
mem["accountNarrative"] = narrative
|
||||||
|
|
||||||
# 5. Score + prioritize
|
# 5. Score + prioritize
|
||||||
briefs = scorer.score_and_prioritize(briefs, account)
|
briefs = scorer.score_and_prioritize(briefs, account)
|
||||||
@@ -69,18 +86,61 @@ def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
bd = b.to_dict()
|
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.save_brief(bd)
|
||||||
store.record_brief(mem, bd)
|
store.record_brief(mem, bd)
|
||||||
render.deliver(bd) # 6. Deliver
|
render.deliver(bd) # 6. Deliver
|
||||||
crm.log_activity(aid, bd) # + CRM write-back stub
|
crm.log_activity(aid, bd) # + CRM write-back stub
|
||||||
delivered.append(bd)
|
delivered.append(bd)
|
||||||
|
|
||||||
# 7. Update memory
|
# 7. Update memory + run cursor
|
||||||
store.save_memory(mem)
|
store.save_memory(mem)
|
||||||
|
_mark_run(account["accountId"])
|
||||||
return delivered
|
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()
|
config.ensure_dirs()
|
||||||
if not config.LLM_CONFIGURED:
|
if not config.LLM_CONFIGURED:
|
||||||
print("ERROR: no LLM provider configured. Set OPENAI_API_KEY (+ OPENAI_BASE_URL/"
|
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).")
|
print("Refusing to run without a reasoning model (no mock fallback).")
|
||||||
return []
|
return []
|
||||||
accounts = crm.load_accounts(tier=tier, account_id=account_id)
|
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:
|
if not accounts:
|
||||||
print("No matching accounts.")
|
print("No matching accounts.")
|
||||||
return []
|
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:
|
if config.EXA_ENABLED:
|
||||||
st = exa.usage_status()
|
st = exa.usage_status()
|
||||||
scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web"
|
scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web"
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class OpportunityBrief:
|
|||||||
created_at: str
|
created_at: str
|
||||||
expires_at: str
|
expires_at: str
|
||||||
status: str = "pending" # pending | accepted | rejected | snoozed | won | lost
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|||||||
Reference in New Issue
Block a user