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:
2026-07-11 19:12:36 -04:00
parent 9131e83c08
commit c2854ba163
23 changed files with 22050 additions and 144 deletions

View File

@@ -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

View File

@@ -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
View 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

View 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
}
]
}

View 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"}
]
}

View File

@@ -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,15 +26,59 @@ 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):
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)) 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):
entry = feedback.record_feedback(args.brief, args.action, args.note or "") entry = feedback.record_feedback(args.brief, args.action, args.note or "")
print(f"Recorded '{args.action}' on brief {args.brief}. " print(f"Recorded '{args.action}' on brief {args.brief}. "
@@ -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,

View 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])

View 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"))

View File

@@ -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

View 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

View File

@@ -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),
) )

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
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'])} &mdash; {_esc(b['capability_name'])}
<span style="float:right;color:#555;font-weight:normal">score {b['composite_score']} &middot; 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']} &middot; 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)

View File

@@ -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":

View 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

View File

@@ -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)

View 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)

View File

@@ -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"

View File

@@ -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)