From 39a338990fbbcad3ecea7ec748556c95033f90bf Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Tue, 23 Jun 2026 11:39:59 -0400 Subject: [PATCH] Initial commit: prospecting agent loop MVP Closed-loop account-intelligence system: harvest -> relevance filter vs. Capability Library -> synthesize -> score -> deliver -> feedback. - 40-capability Broadcom + VMware library - Live Exa.ai news connector with hard free-plan request ceilings - Provider-agnostic LLM layer (Gemini/Anthropic) with offline mock fallback - LLM confidence wired into composite scoring + low-confidence floor - 7-account pilot list Co-Authored-By: Claude Opus 4.8 --- .gitignore | 15 + README.md | 35 + sales-agent/.env.example | 33 + sales-agent/.gitignore | 4 + sales-agent/README.md | 165 ++++ sales-agent/data/accounts.json | 82 ++ sales-agent/data/capabilities.json | 858 +++++++++++++++++ .../data/capabilities.placeholder.json | 144 +++ sales-agent/data/fixtures.json | 97 ++ sales-agent/run.py | 115 +++ sales-agent/src/__init__.py | 1 + sales-agent/src/agents/__init__.py | 0 sales-agent/src/agents/harvester.py | 16 + sales-agent/src/agents/normalizer.py | 22 + sales-agent/src/agents/relevance.py | 115 +++ sales-agent/src/agents/scorer.py | 95 ++ sales-agent/src/agents/synthesizer.py | 161 ++++ sales-agent/src/capabilities.py | 61 ++ sales-agent/src/config.py | 103 ++ sales-agent/src/connectors/__init__.py | 21 + sales-agent/src/connectors/crm.py | 39 + sales-agent/src/connectors/exa.py | 168 ++++ sales-agent/src/connectors/jobs.py | 24 + sales-agent/src/connectors/linkedin.py | 23 + sales-agent/src/connectors/news.py | 35 + sales-agent/src/delivery/__init__.py | 0 sales-agent/src/delivery/digest.py | 60 ++ sales-agent/src/delivery/render.py | 93 ++ sales-agent/src/llm.py | 105 +++ sales-agent/src/memory/__init__.py | 0 sales-agent/src/memory/feedback.py | 121 +++ sales-agent/src/memory/store.py | 121 +++ sales-agent/src/pipeline.py | 95 ++ sales-agent/src/types.py | 109 +++ sales-prospecting-agent-loop.md | 884 ++++++++++++++++++ 35 files changed, 4020 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 sales-agent/.env.example create mode 100644 sales-agent/.gitignore create mode 100644 sales-agent/README.md create mode 100644 sales-agent/data/accounts.json create mode 100644 sales-agent/data/capabilities.json create mode 100644 sales-agent/data/capabilities.placeholder.json create mode 100644 sales-agent/data/fixtures.json create mode 100644 sales-agent/run.py create mode 100644 sales-agent/src/__init__.py create mode 100644 sales-agent/src/agents/__init__.py create mode 100644 sales-agent/src/agents/harvester.py create mode 100644 sales-agent/src/agents/normalizer.py create mode 100644 sales-agent/src/agents/relevance.py create mode 100644 sales-agent/src/agents/scorer.py create mode 100644 sales-agent/src/agents/synthesizer.py create mode 100644 sales-agent/src/capabilities.py create mode 100644 sales-agent/src/config.py create mode 100644 sales-agent/src/connectors/__init__.py create mode 100644 sales-agent/src/connectors/crm.py create mode 100644 sales-agent/src/connectors/exa.py create mode 100644 sales-agent/src/connectors/jobs.py create mode 100644 sales-agent/src/connectors/linkedin.py create mode 100644 sales-agent/src/connectors/news.py create mode 100644 sales-agent/src/delivery/__init__.py create mode 100644 sales-agent/src/delivery/digest.py create mode 100644 sales-agent/src/delivery/render.py create mode 100644 sales-agent/src/llm.py create mode 100644 sales-agent/src/memory/__init__.py create mode 100644 sales-agent/src/memory/feedback.py create mode 100644 sales-agent/src/memory/store.py create mode 100644 sales-agent/src/pipeline.py create mode 100644 sales-agent/src/types.py create mode 100644 sales-prospecting-agent-loop.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5bbd6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Secrets — never commit +**/.env +.env + +# Generated artifacts / local state +**/output/ +sales-agent/output/ + +# Python +**/__pycache__/ +*.pyc +*.pyo + +# OS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f940275 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Prospecting Agent Loop + +A closed-loop, account-intelligence system for enterprise sales prospecting: it +monitors a list of target accounts, harvests business signals (news/web today; +LinkedIn, job postings, and filings to come), evaluates them against a structured +**Capability Library** of sellable products, and routes scored, signal-grounded +opportunity briefs to the right rep — with rep feedback flowing back into scoring. + +## Contents + +- **[`sales-prospecting-agent-loop.md`](sales-prospecting-agent-loop.md)** — the full + design document (architecture, agent loop phases, scoring, feedback loop, roadmap). +- **[`sales-agent/`](sales-agent/)** — the runnable MVP. See + [`sales-agent/README.md`](sales-agent/README.md) for setup and usage. + +## Status (MVP) + +- End-to-end loop runs: harvest → normalize/dedup → relevance filter → synthesize → + score → deliver → feedback → recalibrate. +- **Capability Library** seeded with 40 real Broadcom + VMware capabilities. +- **News connector**: live Exa.ai with hard request ceilings (free-plan safe). +- **LLM reasoning**: provider-agnostic (Gemini or Anthropic), with an offline mock + fallback so the pipeline runs without a key. +- **Pilot accounts**: 7 real companies wired in (~200 to follow). + +Secrets live in `sales-agent/.env` (gitignored). See `sales-agent/.env.example`. + +## Quick start + +```bash +cd sales-agent +cp .env.example .env # add your keys (Exa, Gemini/Anthropic) +python3 run.py loop # run the loop +python3 run.py digest # per-rep daily digest +``` diff --git a/sales-agent/.env.example b/sales-agent/.env.example new file mode 100644 index 0000000..22453e2 --- /dev/null +++ b/sales-agent/.env.example @@ -0,0 +1,33 @@ +# Copy to .env and fill in. The loop runs WITHOUT a key using a mock reasoner. + +# --- LLM provider --- +# LLM_PROVIDER: gemini | anthropic (auto-detected from whichever key is set, +# Gemini preferred, if left blank). Provider with a missing key => mock reasoner. +LLM_PROVIDER=gemini +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.5-flash + +ANTHROPIC_API_KEY= +LLM_MODEL=claude-sonnet-4-6 + +# Force the offline mock reasoner even if a key is set: +# USE_MOCK_LLM=1 + +# Scoring thresholds (design Phase 5) +PRIORITY_THRESHOLD=0.72 +STANDARD_THRESHOLD=0.45 + +# --- Exa.ai news/web harvester --- +# Without a key, the news connector falls back to data/fixtures.json. +EXA_API_KEY= +EXA_ENABLED=1 +# Hard ceilings to stay inside the free plan (~20,000 req/month). The connector +# refuses to call Exa once either cap is reached. Monthly count persists in +# output/state/exa_usage.json and resets at the start of each calendar month. +EXA_MONTHLY_REQUEST_CAP=1000 +EXA_PER_RUN_REQUEST_CAP=25 +EXA_RESULTS_PER_QUERY=5 +EXA_LOOKBACK_DAYS=45 +# Scope news to each account's own domain (1, default) to cut ticker/analyst +# noise, or search the broad web (0). Override per run: loop --no-domain-filter +EXA_DOMAIN_FILTER=1 diff --git a/sales-agent/.gitignore b/sales-agent/.gitignore new file mode 100644 index 0000000..42251b5 --- /dev/null +++ b/sales-agent/.gitignore @@ -0,0 +1,4 @@ +output/ +.env +__pycache__/ +*.pyc diff --git a/sales-agent/README.md b/sales-agent/README.md new file mode 100644 index 0000000..8e1db2b --- /dev/null +++ b/sales-agent/README.md @@ -0,0 +1,165 @@ +# Sales Prospecting Agent Loop — MVP (v1) + +A runnable first cut of the closed-loop account-intelligence system described in +[`../sales-prospecting-agent-loop.md`](../sales-prospecting-agent-loop.md). It +harvests signals per account, evaluates them against a capability library, scores +and synthesizes opportunity briefs, delivers them, and feeds rep feedback back into +scoring — the full loop, end to end. + +## ⚠️ What's real vs. placeholder in this version + +This MVP is built to **run today with zero setup** so we can review output quality +and iterate. Deliberate v1 simplifications (we'll revisit each tomorrow): + +| Design calls for | v1 MVP uses | Why | +|---|---|---| +| TypeScript / Node + BullMQ | **Python 3, stdlib only** | Node isn't installed on this machine; concepts map 1:1. Easy to port. | +| Proxycurl/Apify, JSearch, SEC EDGAR | **Fixture file** (`data/fixtures.json`) | No API keys needed; swap each connector for the real API. | +| Exa.ai news/web search | **LIVE** when `EXA_API_KEY` set (else fixtures) | Real connector with hard request ceilings — see below. | +| Salesforce/HubSpot | **`data/accounts.json`** + console write-back | Read context + log-activity stub. | +| Postgres + Redis + pgvector | **JSON files** under `output/state/` | Hash dedup works; semantic dedup is a documented v2 hook. | +| Vector ANN capability pre-filter | **Keyword-overlap pre-filter** | Same interface, no embedding service. | +| Slack Bolt + SendGrid | **Console + markdown files** | Briefs render Slack-style; digest writes to `output/digest.md`. | +| LLM relevance + synthesis | **Gemini or Anthropic when a key is set, else a deterministic mock reasoner** | Provider-agnostic (`LLM_PROVIDER`); loop runs offline without a key. | + +## Run it + +```bash +cd sales-agent + +# 1. Run the loop over all target accounts (harvest -> deliver) +python3 run.py loop + +# Single account or one tier: +python3 run.py loop --account acct_meridian +python3 run.py loop --tier strategic + +# 2. See the per-rep daily digest +python3 run.py digest +python3 run.py digest --rep rep_sarah + +# 3. Record rep feedback (closes the loop) +python3 run.py feedback --brief --action act_on +# actions: act_on | snooze | reject | won | lost (reject also suppresses 14d) + +# 4. Recompute scoring weights from feedback (the weekly recalibration job) +python3 run.py recalibrate + +# Helpers +python3 run.py accounts # list target accounts +python3 run.py briefs # list all generated briefs + status +``` + +### LLM reasoning (provider-agnostic) + +The relevance filter and synthesizer call whichever provider is configured. Set in +`.env`: + +```bash +# Gemini (current default) +LLM_PROVIDER=gemini +GEMINI_API_KEY=... +GEMINI_MODEL=gemini-2.5-flash + +# or Anthropic +LLM_PROVIDER=anthropic +ANTHROPIC_API_KEY=... +LLM_MODEL=claude-sonnet-4-6 +``` + +If `LLM_PROVIDER` is blank it auto-detects from whichever key is present (Gemini +preferred). With no key, a deterministic mock reasoner produces grounded briefs so +the full pipeline still runs offline. Both providers use plain `urllib` — no SDKs. + +## Exa.ai news connector & free-plan protection + +The news harvester ([`src/connectors/exa.py`](src/connectors/exa.py)) calls the real +Exa search API (`POST https://api.exa.ai/search`) when `EXA_API_KEY` is set, and +falls back to fixtures otherwise. **Two hard ceilings keep you inside the free plan** +(Exa's free tier is ~20,000 requests/month; we default far below that): + +- `EXA_MONTHLY_REQUEST_CAP` (default **1000**) — a persistent counter in + `output/state/exa_usage.json` that resets at the start of each calendar month. + Once hit, the connector refuses to call Exa and falls back to fixtures. +- `EXA_PER_RUN_REQUEST_CAP` (default **25**) — a per-process cap so a single loop + can't burn through the budget. + +A request is only counted when a call actually reaches Exa (a billable call); +network failures that never hit Exa are not counted. Each account uses **one** Exa +request per loop run (so the default 3 sample accounts = 3 requests/run). + +```bash +python3 run.py exa-usage # show remaining monthly budget +``` + +### Domain scoping (default on) + +By default, news search is **scoped to each account's own `domain`** (via Exa +`includeDomains`) — newsroom, press releases, investor-relations — which cuts out +stock-ticker and analyst noise from a bare company-name search. Toggle it: + +```bash +python3 run.py loop --no-domain-filter # widen to broad third-party web coverage +# or set EXA_DOMAIN_FILTER=0 in .env to default to broad +``` + +Note: domain scoping only returns results when the account's real domain has indexed +content. The fictional sample accounts return 0 domain-scoped articles by design — +real target-account domains will return their first-party news. + +Tune all limits in `.env` (`EXA_MONTHLY_REQUEST_CAP`, `EXA_PER_RUN_REQUEST_CAP`, +`EXA_RESULTS_PER_QUERY`, `EXA_LOOKBACK_DAYS`). Set `EXA_ENABLED=0` to force fixtures. + +## How the loop maps to the design + +``` +run.py loop + └─ src/pipeline.run_account_cycle (design §7.3) + 1. agents/harvester Phase 1 signal harvest (connectors/*) + 2. agents/normalizer Phase 2 normalize + hash dedup + 3. agents/relevance Phase 3 LLM vs. capability library + 4. agents/synthesizer Phase 4 opportunity briefs + 5. agents/scorer Phase 5 composite score + priority + 6. delivery/render Phase 7 deliver (console/md) + CRM log + 7. memory/store Phase 6 account memory + suppression + feedback ─ memory/feedback §6 feedback store + recalibration +``` + +## Project layout + +``` +sales-agent/ +├── run.py CLI entrypoint +├── data/ +│ ├── accounts.json target accounts (stand-in for CRM) +│ ├── capabilities.json the Capability Library (the "brain") +│ └── fixtures.json placeholder harvested signals +├── src/ +│ ├── config.py paths, thresholds, .env loader +│ ├── types.py RawSignal / NormalizedSignal / Brief dataclasses +│ ├── llm.py Anthropic call (urllib) + mock fallback +│ ├── capabilities.py library loader + keyword pre-filter +│ ├── connectors/ linkedin, news, jobs, crm (fixture-backed) +│ ├── agents/ harvester, normalizer, relevance, synthesizer, scorer +│ ├── memory/ store (dedup/memory/briefs) + feedback (recalibration) +│ ├── delivery/ render (briefs) + digest +│ └── pipeline.py the per-account orchestration +└── output/ generated briefs, digest, and JSON state (created on run) +``` + +## Suggested review focus for tomorrow + +1. **Capability Library** (`data/capabilities.json`) — now seeded with 40 real + Broadcom/VMware capabilities across Mainframe (13), Enterprise Software (9), + Identity & Access Management (6), and VMware by Broadcom (12). Review wording of + buying-signals/anti-signals and add any missing SKUs. The prior placeholder library + is kept at `data/capabilities.placeholder.json`. +2. **Accounts** (`data/accounts.json`) — sample BSG target accounts; swap for a real + target-account list + CRM pull. +3. **Scoring weights** — the composite formula in `agents/scorer.py` uses the design's + weights; tune the tier/seniority/timing tables. +4. **Connectors** — Exa.ai news is now **live** (with request caps). Improve query + precision (a bare company name pulls in stock-ticker noise — add domain filtering + via the account `domain`), then wire the next source (jobs/LinkedIn). +5. **Reasoning quality** — set an API key and compare Claude briefs vs. the mock. +``` diff --git a/sales-agent/data/accounts.json b/sales-agent/data/accounts.json new file mode 100644 index 0000000..bc67b37 --- /dev/null +++ b/sales-agent/data/accounts.json @@ -0,0 +1,82 @@ +{ + "_note": "Pilot target list (7 accounts; ~200 total to come). REAL fields: accountName, domain, industry. PLACEHOLDER fields to confirm from CRM/install-base: accountTier, assignedRep, currentProducts (drives anti-signal suppression — empty here means nothing is suppressed yet), targetContacts (empty; the 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 + } + ] +} diff --git a/sales-agent/data/capabilities.json b/sales-agent/data/capabilities.json new file mode 100644 index 0000000..8e97eab --- /dev/null +++ b/sales-agent/data/capabilities.json @@ -0,0 +1,858 @@ +{ + "version": "0.2.0-broadcom", + "lastUpdated": "2026-06-23", + "_source": "Broadcom Mainframe Software, Enterprise Software, and Identity & Access Management (Cybersecurity) groupings + VMware by Broadcom product line. Built from broadcom.com/products and vmware.com/products.", + "_note": "Each capability maps to a real Broadcom/VMware product or a tight cluster of related SKUs. Anti-signals reference current-product ownership from CRM to suppress what the account already owns.", + "capabilities": [ + { + "id": "cap_mf_endevor", + "grouping": "Mainframe Software", + "category": "DevOps / Application Development", + "name": "Endevor — Mainframe Source & Change Management", + "description": "Automates and governs mainframe application change management (SCM), securing and standardizing how mainframe software assets are built and released.", + "businessProblems": [ + "Manual, error-prone mainframe release processes", + "Lack of audit trail and governance over mainframe code changes", + "Mainframe teams excluded from enterprise CI/CD pipelines", + "Knowledge loss as veteran mainframe developers retire" + ], + "buyingSignals": [ + "Hiring mainframe developers, z/OS engineers, or DevOps roles touching the mainframe", + "Executive posts about mainframe modernization or DevOps on Z", + "Mention of integrating mainframe into Git/CI-CD or pipeline automation", + "Audit findings on change control for core systems" + ], + "antiSignals": [ + "Already owns Endevor per CRM", + "Recently standardized on a competing mainframe SCM" + ], + "targetBuyer": { "titles": ["VP Application Development", "Director Mainframe", "Head of DevOps"], "departments": ["IT", "Application Development"] }, + "keywords": ["mainframe", "endevor", "scm", "change management", "devops", "ci/cd", "pipeline", "z/os", "release", "source control"] + }, + { + "id": "cap_mf_brightside", + "grouping": "Mainframe Software", + "category": "DevOps / Application Development", + "name": "Brightside — Zowe-based Mainframe DevOps", + "description": "Open, Zowe-based framework that gives developers modern, self-service tooling (CLI, APIs, VS Code) to work with z/OS the way they work with the cloud.", + "businessProblems": [ + "Younger developers reluctant to use green-screen mainframe tools", + "Mainframe siloed from modern developer toolchains", + "Slow onboarding of new mainframe talent", + "Manual, repetitive z/OS operational tasks" + ], + "buyingSignals": [ + "Job postings mentioning Zowe, VS Code for mainframe, or modern mainframe tooling", + "Executive posts about attracting next-gen mainframe talent", + "Initiatives to standardize developer experience across platforms", + "Mentions of automating z/OS tasks or self-service" + ], + "antiSignals": ["Already owns Brightside per CRM"], + "targetBuyer": { "titles": ["Director Mainframe", "VP Engineering", "Head of Platform"], "departments": ["IT", "Application Development"] }, + "keywords": ["zowe", "brightside", "mainframe", "developer experience", "vs code", "cli", "self-service", "modernization", "automation"] + }, + { + "id": "cap_mf_idms", + "grouping": "Mainframe Software", + "category": "Databases", + "name": "IDMS — Mainframe Database Management System", + "description": "High-performance network/relational mainframe DBMS for mission-critical, high-volume transaction workloads, web-enabled for modern access.", + "businessProblems": [ + "Core transactional systems require extreme reliability and throughput", + "Need to web/API-enable legacy mainframe data", + "Scaling transaction volume without re-platforming" + ], + "buyingSignals": [ + "Hiring for mainframe DBA or core systems roles", + "Mentions of high-volume transaction growth on legacy systems", + "Initiatives to expose mainframe data via APIs" + ], + "antiSignals": ["Already owns IDMS per CRM", "Migrating off the mainframe DBMS entirely"], + "targetBuyer": { "titles": ["VP Infrastructure", "Director Database", "Mainframe DBA Lead"], "departments": ["IT", "Data"] }, + "keywords": ["idms", "mainframe", "database", "dbms", "transaction", "rdbms", "core systems", "throughput"] + }, + { + "id": "cap_mf_datacom", + "grouping": "Mainframe Software", + "category": "Databases", + "name": "Datacom — High-Performance Mainframe Database", + "description": "Fault-tolerant, high-volume mainframe database repository for enterprise-wide workloads with strong availability characteristics.", + "businessProblems": [ + "Need fault tolerance and continuous availability for core data", + "High-volume enterprise workloads outgrowing current repository", + "Pressure to modernize data access on the mainframe" + ], + "buyingSignals": [ + "Hiring mainframe DBAs or reliability engineers", + "Mentions of availability/uptime targets for core systems", + "Workload growth or consolidation onto the mainframe" + ], + "antiSignals": ["Already owns Datacom per CRM"], + "targetBuyer": { "titles": ["VP Infrastructure", "Director Database"], "departments": ["IT", "Data"] }, + "keywords": ["datacom", "mainframe", "database", "availability", "fault tolerant", "high volume", "repository"] + }, + { + "id": "cap_mf_db2tools", + "grouping": "Mainframe Software", + "category": "Databases", + "name": "Database Management for Db2 for z/OS", + "description": "Suite of tools to administer, tune, back up, and recover Db2 for z/OS — reducing cost and improving performance of Db2 environments.", + "businessProblems": [ + "Db2 performance bottlenecks and rising MIPS cost", + "Manual, slow Db2 backup/recovery and reorg processes", + "DBA productivity limits as Db2 estate grows" + ], + "buyingSignals": [ + "Hiring Db2 DBAs or performance engineers", + "Mentions of MIPS/MSU cost reduction initiatives", + "Complaints about Db2 performance or batch windows" + ], + "antiSignals": ["Already owns Broadcom Db2 tools per CRM", "Standardized on a competing Db2 tools vendor"], + "targetBuyer": { "titles": ["Director Database", "VP Infrastructure", "Db2 DBA Lead"], "departments": ["IT", "Data"] }, + "keywords": ["db2", "mainframe", "database tools", "performance", "tuning", "backup", "recovery", "reorg", "mips", "cost"] + }, + { + "id": "cap_mf_security", + "grouping": "Mainframe Software", + "category": "Security", + "name": "Mainframe Security — Top Secret & ACF2", + "description": "External security managers (ESM) for z/OS providing access control, authentication, and protection of mainframe resources at scale.", + "businessProblems": [ + "Protecting core mainframe systems from insider and external threats", + "Demonstrating access controls for audit and regulatory exams", + "Managing mainframe identities and entitlements consistently" + ], + "buyingSignals": [ + "Hiring mainframe security or z/OS security admins", + "Regulatory exam, audit finding, or breach involving core systems", + "Mentions of zero-trust or access governance on the mainframe", + "M&A requiring consolidation of mainframe security domains" + ], + "antiSignals": ["Already owns Top Secret or ACF2 per CRM", "Standardized on RACF"], + "targetBuyer": { "titles": ["CISO", "Director Mainframe Security", "VP Infrastructure"], "departments": ["Security", "IT"] }, + "keywords": ["mainframe", "security", "top secret", "acf2", "esm", "access control", "z/os", "audit", "zero trust", "authentication"] + }, + { + "id": "cap_mf_compliance", + "grouping": "Mainframe Software", + "category": "Security / Compliance", + "name": "Mainframe Compliance & Audit (Compliance Event Manager, Cleanup, Data Content Discovery)", + "description": "Continuous compliance monitoring, security database cleanup, and sensitive-data discovery for z/OS to reduce audit effort and risk.", + "businessProblems": [ + "Manual, spreadsheet-driven mainframe audit preparation", + "Stale or excessive access rights in the security database", + "Unknown sensitive data residing on the mainframe" + ], + "buyingSignals": [ + "Audit findings or material weakness involving core systems", + "Hiring GRC, internal audit, or compliance roles touching IT", + "New regulatory scope (PCI, SOX, GDPR) affecting mainframe data", + "Mentions of continuous compliance or access recertification" + ], + "antiSignals": ["Already owns Compliance Event Manager per CRM"], + "targetBuyer": { "titles": ["CISO", "VP Compliance", "Head of Internal Audit"], "departments": ["Security", "Compliance", "Risk"] }, + "keywords": ["mainframe", "compliance", "audit", "cleanup", "data discovery", "sox", "pci", "gdpr", "recertification", "controls"] + }, + { + "id": "cap_mf_automation", + "grouping": "Mainframe Software", + "category": "Operations / Automation", + "name": "OPS/MVS — Mainframe Event Management & Automation", + "description": "Automates z/OS operations, event detection, and response to reduce outages, manual intervention, and operator workload.", + "businessProblems": [ + "Manual mainframe operations and console firefighting", + "Outages from delayed or missed event response", + "Operations headcount strain as veterans retire" + ], + "buyingSignals": [ + "Hiring mainframe operations / automation engineers", + "Mentions of reducing manual intervention or improving uptime on Z", + "Initiatives to automate IT operations or NOC modernization" + ], + "antiSignals": ["Already owns OPS/MVS per CRM"], + "targetBuyer": { "titles": ["Director Mainframe Operations", "VP Infrastructure", "Head of IT Ops"], "departments": ["IT", "Operations"] }, + "keywords": ["mainframe", "automation", "ops/mvs", "operations", "event management", "console", "uptime", "outage", "z/os"] + }, + { + "id": "cap_mf_sysview", + "grouping": "Mainframe Software", + "category": "Operations / Performance", + "name": "SYSVIEW — Mainframe Performance Management", + "description": "Real-time z/OS performance monitoring and management to optimize system health, reduce MIPS consumption, and prevent performance incidents.", + "businessProblems": [ + "Rising mainframe (MIPS/MSU) cost pressure", + "Performance incidents impacting core services", + "Limited real-time visibility into z/OS health" + ], + "buyingSignals": [ + "Mentions of mainframe cost optimization or MIPS reduction", + "Hiring performance / capacity engineers", + "Complaints about batch window or response time" + ], + "antiSignals": ["Already owns SYSVIEW per CRM"], + "targetBuyer": { "titles": ["Director Mainframe Operations", "VP Infrastructure"], "departments": ["IT", "Operations"] }, + "keywords": ["mainframe", "sysview", "performance", "monitoring", "mips", "msu", "capacity", "z/os", "cost"] + }, + { + "id": "cap_mf_aiops", + "grouping": "Mainframe Software", + "category": "Operations / AIOps", + "name": "Mainframe Operational Intelligence (AIOps for Z)", + "description": "Applies machine learning to mainframe operational data to predict anomalies and surface insights across the z/OS estate.", + "businessProblems": [ + "Reactive, alert-driven mainframe operations", + "Too much operational data, too little insight", + "Difficulty predicting incidents before they impact service" + ], + "buyingSignals": [ + "Executive posts about AIOps, predictive operations, or observability", + "Hiring data/observability roles spanning the mainframe", + "Initiatives to unify ops visibility across platforms" + ], + "antiSignals": ["Already owns Mainframe Operational Intelligence per CRM"], + "targetBuyer": { "titles": ["VP IT Operations", "Director Mainframe", "Head of Observability"], "departments": ["IT", "Operations"] }, + "keywords": ["mainframe", "aiops", "operational intelligence", "machine learning", "predictive", "anomaly", "observability", "insights"] + }, + { + "id": "cap_mf_workload_automation", + "grouping": "Mainframe Software", + "category": "Workload Automation", + "name": "Workload Automation CA 7 — Mainframe Job Scheduling", + "description": "Enterprise job scheduling and workload automation for the mainframe, ensuring reliable, on-time batch processing at scale.", + "businessProblems": [ + "Batch SLAs missed or fragile job schedules", + "Manual coordination of complex cross-platform batch", + "Limited visibility and recovery for failed jobs" + ], + "buyingSignals": [ + "Hiring batch/scheduling or workload automation engineers", + "Mentions of batch modernization or SLA reliability", + "M&A requiring consolidation of scheduling environments" + ], + "antiSignals": ["Already owns CA 7 per CRM", "Standardized on a competing scheduler"], + "targetBuyer": { "titles": ["Director Mainframe Operations", "VP Infrastructure"], "departments": ["IT", "Operations"] }, + "keywords": ["mainframe", "workload automation", "ca 7", "scheduling", "batch", "jobs", "sla", "orchestration"] + }, + { + "id": "cap_mf_network", + "grouping": "Mainframe Software", + "category": "Network Management", + "name": "NetMaster — Mainframe Network Management & Automation", + "description": "Monitors and automates mainframe network (SNA/IP) operations to maximize availability and performance of mission-critical connectivity.", + "businessProblems": [ + "Mainframe network outages impacting core services", + "Manual network troubleshooting on z/OS", + "Limited visibility across SNA and TCP/IP on the mainframe" + ], + "buyingSignals": [ + "Hiring mainframe network engineers", + "Mentions of network reliability or automation initiatives", + "Connectivity incidents affecting core transactions" + ], + "antiSignals": ["Already owns NetMaster per CRM"], + "targetBuyer": { "titles": ["Director Network", "VP Infrastructure"], "departments": ["IT", "Network"] }, + "keywords": ["mainframe", "netmaster", "network", "sna", "tcp/ip", "connectivity", "availability", "automation"] + }, + { + "id": "cap_mf_storage", + "grouping": "Mainframe Software", + "category": "Storage & Output Management", + "name": "Mainframe Storage Management (Vantage, Disk Backup & Restore)", + "description": "Optimizes, monitors, and protects growing mainframe storage — managing capacity, performance, and backup/recovery of z/OS storage.", + "businessProblems": [ + "Growing mainframe storage cost and capacity pressure", + "Manual storage administration and reporting", + "Backup/recovery risk for critical datasets" + ], + "buyingSignals": [ + "Mentions of storage cost optimization on the mainframe", + "Hiring storage administrators", + "Data growth or retention/compliance initiatives" + ], + "antiSignals": ["Already owns Broadcom mainframe storage tools per CRM"], + "targetBuyer": { "titles": ["Director Storage", "VP Infrastructure"], "departments": ["IT", "Operations"] }, + "keywords": ["mainframe", "storage", "vantage", "backup", "restore", "capacity", "disk", "dataset", "cost"] + }, + { + "id": "cap_es_rally", + "grouping": "Enterprise Software", + "category": "ValueOps — Value Stream Management", + "name": "Rally — Enterprise Agile Management", + "description": "Enterprise agile planning and management platform that aligns teams, programs, and portfolios to accelerate business agility.", + "businessProblems": [ + "Scaling agile beyond individual teams", + "Poor visibility into delivery progress across programs", + "Disconnect between strategy and what teams build" + ], + "buyingSignals": [ + "Executive posts about agile transformation or business agility", + "Hiring agile coaches, RTEs, or program managers", + "Mentions of SAFe adoption or scaling agile", + "Reorg toward product-/value-stream operating model" + ], + "antiSignals": ["Already owns Rally per CRM", "Standardized on a competing agile platform"], + "targetBuyer": { "titles": ["VP Engineering", "Head of Agile / Transformation", "CTO"], "departments": ["Engineering", "Transformation"] }, + "keywords": ["rally", "agile", "safe", "transformation", "agile management", "program increment", "business agility", "value stream", "scrum"] + }, + { + "id": "cap_es_clarity", + "grouping": "Enterprise Software", + "category": "ValueOps — Value Stream Management", + "name": "Clarity — Strategic Portfolio Management", + "description": "Strategic portfolio, investment, and product management platform that connects funding and strategy to execution.", + "businessProblems": [ + "Investment decisions disconnected from delivery outcomes", + "Manual, spreadsheet-driven portfolio and capacity planning", + "Inability to reprioritize spend quickly against strategy" + ], + "buyingSignals": [ + "Executive posts about portfolio prioritization or cost discipline", + "Hiring PMO, portfolio, or strategic finance roles", + "Mentions of zero-based budgeting or investment realignment", + "Transformation or restructuring program announced" + ], + "antiSignals": ["Already owns Clarity per CRM", "Standardized on a competing PPM/SPM tool"], + "targetBuyer": { "titles": ["CIO", "Head of PMO", "VP Strategy"], "departments": ["IT", "PMO", "Strategy"] }, + "keywords": ["clarity", "portfolio", "spm", "ppm", "investment", "pmo", "budgeting", "prioritization", "strategy", "capacity planning"] + }, + { + "id": "cap_es_valueops_insights", + "grouping": "Enterprise Software", + "category": "ValueOps — Value Stream Management", + "name": "ValueOps Insights & ConnectALL — Value Stream Management", + "description": "Measures and improves digital value-stream performance and integrates the toolchain (Jira, ADO, ServiceNow) end to end.", + "businessProblems": [ + "No unified view of flow from idea to value", + "Fragmented, disconnected delivery toolchains", + "Difficulty proving ROI of transformation investments" + ], + "buyingSignals": [ + "Executive posts about value stream management or flow metrics", + "Mentions of toolchain integration (Jira/ADO/ServiceNow)", + "Hiring value-stream or transformation measurement roles" + ], + "antiSignals": ["Already owns ValueOps Insights per CRM"], + "targetBuyer": { "titles": ["Head of Transformation", "VP Engineering", "CIO"], "departments": ["Transformation", "Engineering"] }, + "keywords": ["valueops", "value stream", "vsm", "insights", "connectall", "flow", "toolchain integration", "jira", "metrics", "roi"] + }, + { + "id": "cap_es_dx_apm", + "grouping": "Enterprise Software", + "category": "DX — Observability & AIOps", + "name": "DX Application Performance Management (DX APM)", + "description": "Full-stack application performance monitoring with AI-driven analytics to detect, triage, and resolve performance issues fast.", + "businessProblems": [ + "Application performance incidents hurting customer experience", + "Slow root-cause analysis across complex stacks", + "Siloed monitoring tools with no end-to-end view" + ], + "buyingSignals": [ + "Executive posts about reliability, uptime, or customer experience", + "Hiring SRE, observability, or performance engineers", + "Mentions of major outage or digital experience initiatives", + "Cloud migration increasing monitoring complexity" + ], + "antiSignals": ["Already owns DX APM per CRM", "Standardized on a competing APM/observability vendor"], + "targetBuyer": { "titles": ["VP IT Operations", "Head of SRE", "CIO"], "departments": ["IT", "Operations"] }, + "keywords": ["apm", "dx apm", "observability", "application performance", "monitoring", "sre", "reliability", "root cause", "uptime"] + }, + { + "id": "cap_es_dx_aiops", + "grouping": "Enterprise Software", + "category": "DX — Observability & AIOps", + "name": "DX Operational Observability & AIOps", + "description": "Unifies metrics, events, logs, and traces with AIOps to reduce noise, predict issues, and automate IT operations.", + "businessProblems": [ + "Alert storms and tool sprawl overwhelming ops teams", + "Reactive operations with slow MTTR", + "No correlated view across hybrid infrastructure" + ], + "buyingSignals": [ + "Executive posts about AIOps, noise reduction, or MTTR", + "Hiring observability/AIOps roles", + "Mentions of consolidating monitoring tools", + "Major incident driving operations modernization" + ], + "antiSignals": ["Already owns DX OI/AIOps per CRM"], + "targetBuyer": { "titles": ["VP IT Operations", "Head of Observability", "CIO"], "departments": ["IT", "Operations"] }, + "keywords": ["aiops", "observability", "operational intelligence", "mttr", "noise reduction", "events", "logs", "traces", "automation"] + }, + { + "id": "cap_es_dx_netops", + "grouping": "Enterprise Software", + "category": "DX — Network Operations", + "name": "DX NetOps — Network Operations Management", + "description": "Unified network monitoring and analytics for modern, multi-vendor, hybrid networks — from data center to SD-WAN and cloud.", + "businessProblems": [ + "Blind spots across multi-vendor, hybrid networks", + "Slow network fault isolation and outage resolution", + "Tool sprawl across network monitoring" + ], + "buyingSignals": [ + "Hiring network operations or NetOps engineers", + "Mentions of SD-WAN, network modernization, or multi-cloud connectivity", + "Network outage or performance incident", + "Merger combining disparate network estates" + ], + "antiSignals": ["Already owns DX NetOps per CRM", "Standardized on a competing NPM vendor"], + "targetBuyer": { "titles": ["Director Network", "VP Infrastructure", "Head of NetOps"], "departments": ["IT", "Network"] }, + "keywords": ["netops", "network monitoring", "network operations", "sd-wan", "multi-vendor", "fault", "hybrid network", "npm"] + }, + { + "id": "cap_es_appneta", + "grouping": "Enterprise Software", + "category": "DX — Network Operations", + "name": "AppNeta — Network Performance Monitoring (Active/Synthetic)", + "description": "End-user experience and active path monitoring across the internet and SaaS, ideal for distributed/remote workforces.", + "businessProblems": [ + "Poor SaaS/app experience for remote and branch users", + "No visibility into networks you don't own (ISP, SaaS paths)", + "Difficulty proving where experience problems originate" + ], + "buyingSignals": [ + "Mentions of remote work, branch, or SaaS experience problems", + "Hiring digital-experience or network roles", + "Office/branch expansion or hybrid-work initiatives" + ], + "antiSignals": ["Already owns AppNeta per CRM"], + "targetBuyer": { "titles": ["Director Network", "Head of Digital Experience", "VP IT"], "departments": ["IT", "Network"] }, + "keywords": ["appneta", "network performance", "synthetic monitoring", "end user experience", "saas", "remote work", "branch", "path"] + }, + { + "id": "cap_es_automation", + "grouping": "Enterprise Software", + "category": "Automation", + "name": "Automic / AutoSys — Workload Automation & Orchestration", + "description": "Enterprise automation and orchestration of business and IT workloads across hybrid environments, replacing brittle scripting and siloed schedulers.", + "businessProblems": [ + "Fragmented schedulers and manual scripting across platforms", + "Batch/process SLAs at risk as complexity grows", + "Cloud migration requiring orchestration across hybrid estate" + ], + "buyingSignals": [ + "Hiring automation, orchestration, or RevOps/ITOps roles", + "Mentions of process automation or eliminating manual handoffs", + "Cloud migration or DevOps pipeline expansion", + "M&A requiring consolidation of automation tooling" + ], + "antiSignals": ["Already owns Automic or AutoSys per CRM", "Standardized on a competing orchestration platform"], + "targetBuyer": { "titles": ["VP IT Operations", "Director Automation", "Head of DevOps"], "departments": ["IT", "Operations"] }, + "keywords": ["automic", "autosys", "workload automation", "orchestration", "scheduling", "automation", "batch", "hybrid", "process"] + }, + { + "id": "cap_es_layer7", + "grouping": "Enterprise Software", + "category": "API Management", + "name": "Layer7 — API Management", + "description": "Secure, govern, and scale APIs across the enterprise with an API gateway, developer portal, and lifecycle management.", + "businessProblems": [ + "Ungoverned API sprawl and inconsistent security", + "Slow partner/developer onboarding to APIs", + "Need to expose legacy systems safely via APIs" + ], + "buyingSignals": [ + "Hiring API, integration, or platform engineers", + "Executive posts about API-first, open banking, or ecosystems", + "Mentions of digital platform or partner integration initiatives", + "Legacy/mainframe modernization exposing services via APIs" + ], + "antiSignals": ["Already owns Layer7 per CRM", "Standardized on a competing API gateway"], + "targetBuyer": { "titles": ["Head of Integration", "VP Platform", "Chief Architect"], "departments": ["IT", "Engineering"] }, + "keywords": ["layer7", "api management", "api gateway", "developer portal", "integration", "api-first", "open banking", "microservices"] + }, + { + "id": "cap_iam_siteminder", + "grouping": "Cybersecurity — Identity & Access Management", + "category": "Access Management", + "name": "Symantec SiteMinder — Access Management & SSO", + "description": "Web/enterprise access management providing single sign-on, federation, and centralized authentication/authorization at scale.", + "businessProblems": [ + "Inconsistent authentication across many applications", + "Poor user experience from multiple logins", + "Federation gaps with partners and SaaS" + ], + "buyingSignals": [ + "Hiring IAM, SSO, or identity engineers", + "Mentions of zero-trust, SSO, or access modernization", + "M&A requiring federation across identity domains", + "Audit findings on access governance" + ], + "antiSignals": ["Already owns SiteMinder per CRM", "Standardized on a competing access-management platform"], + "targetBuyer": { "titles": ["CISO", "Director IAM", "VP Security"], "departments": ["Security", "IT"] }, + "keywords": ["siteminder", "access management", "sso", "single sign-on", "federation", "authentication", "zero trust", "iam"] + }, + { + "id": "cap_iam_vip", + "grouping": "Cybersecurity — Identity & Access Management", + "category": "Authentication / MFA", + "name": "Symantec VIP — Multi-Factor & Risk-Based Authentication", + "description": "Cloud-based MFA and risk-based authentication to secure user access with strong, adaptive verification.", + "businessProblems": [ + "Account takeover and credential-based attacks", + "Weak or password-only authentication", + "Friction between security and user experience" + ], + "buyingSignals": [ + "Mentions of MFA mandates, phishing, or account takeover", + "Cyber-insurance or regulatory MFA requirements", + "Breach or credential-stuffing incident disclosed", + "Hiring identity/security engineers" + ], + "antiSignals": ["Already owns VIP per CRM", "Standardized on a competing MFA provider"], + "targetBuyer": { "titles": ["CISO", "Director IAM", "VP Security"], "departments": ["Security", "IT"] }, + "keywords": ["vip", "mfa", "multi-factor", "authentication", "risk-based", "adaptive", "account takeover", "phishing", "passwordless"] + }, + { + "id": "cap_iam_pam", + "grouping": "Cybersecurity — Identity & Access Management", + "category": "Privileged Access", + "name": "Symantec PAM — Privileged Access Management", + "description": "Secures, controls, and records privileged/administrative access across cloud, virtual, and physical environments.", + "businessProblems": [ + "Unmanaged privileged credentials and standing access", + "No recording/audit of administrator activity", + "Insider-threat and lateral-movement risk" + ], + "buyingSignals": [ + "Audit finding or breach involving privileged accounts", + "Cyber-insurance requiring PAM controls", + "Mentions of zero-trust, least privilege, or secrets management", + "Hiring security engineers focused on access" + ], + "antiSignals": ["Already owns Symantec PAM per CRM", "Standardized on a competing PAM vendor"], + "targetBuyer": { "titles": ["CISO", "Director IAM", "Head of Security Operations"], "departments": ["Security", "IT"] }, + "keywords": ["pam", "privileged access", "credentials", "least privilege", "secrets", "session recording", "zero trust", "insider threat"] + }, + { + "id": "cap_iam_iga", + "grouping": "Cybersecurity — Identity & Access Management", + "category": "Identity Governance", + "name": "Symantec Identity Governance & Administration (Identity Suite)", + "description": "Automates identity lifecycle, access requests, and certification/recertification to enforce least privilege and pass audits.", + "businessProblems": [ + "Manual joiner/mover/leaver and access provisioning", + "Access-recertification campaigns done in spreadsheets", + "Excessive entitlements failing audit" + ], + "buyingSignals": [ + "Audit findings on access governance or segregation of duties", + "Hiring IAM/IGA or compliance roles", + "Mentions of identity lifecycle automation or recertification", + "M&A requiring identity consolidation" + ], + "antiSignals": ["Already owns Symantec Identity Suite per CRM", "Standardized on a competing IGA vendor"], + "targetBuyer": { "titles": ["CISO", "Director IAM", "VP Compliance"], "departments": ["Security", "Compliance"] }, + "keywords": ["identity governance", "iga", "identity suite", "provisioning", "recertification", "least privilege", "segregation of duties", "lifecycle", "entitlements"] + }, + { + "id": "cap_iam_directory", + "grouping": "Cybersecurity — Identity & Access Management", + "category": "Directory", + "name": "Symantec Directory — High-Scale Identity Directory", + "description": "High-performance, scalable directory service underpinning large identity deployments with strong availability.", + "businessProblems": [ + "Directory scale/performance limits for large user bases", + "Fragmented directories after mergers", + "Availability requirements for identity infrastructure" + ], + "buyingSignals": [ + "Mentions of identity infrastructure modernization", + "Large-scale customer identity (CIAM) initiatives", + "M&A requiring directory consolidation" + ], + "antiSignals": ["Already owns Symantec Directory per CRM"], + "targetBuyer": { "titles": ["Director IAM", "Chief Architect", "VP Security"], "departments": ["Security", "IT"] }, + "keywords": ["directory", "ldap", "identity", "ciam", "scale", "availability", "consolidation"] + }, + { + "id": "cap_iam_advauth", + "grouping": "Cybersecurity — Identity & Access Management", + "category": "Authentication / CIAM", + "name": "Advanced Authentication & VIP Authentication Hub (CIAM)", + "description": "Centralized, modern authentication services (incl. passwordless and orchestration) for workforce and customer identity.", + "businessProblems": [ + "Inconsistent authentication across customer and workforce apps", + "Need for passwordless / modern auth standards", + "Customer-facing login friction and abandonment" + ], + "buyingSignals": [ + "Mentions of CIAM, passwordless, or login experience", + "Customer-facing digital initiatives or app launches", + "Hiring identity/CIAM engineers" + ], + "antiSignals": ["Already owns Advanced Authentication / VIP Auth Hub per CRM"], + "targetBuyer": { "titles": ["CISO", "Director IAM", "Head of Digital"], "departments": ["Security", "Digital"] }, + "keywords": ["advanced authentication", "ciam", "passwordless", "authentication hub", "login", "customer identity", "orchestration"] + }, + { + "id": "cap_vmw_vcf", + "grouping": "VMware by Broadcom", + "category": "Cloud Platform", + "name": "VMware Cloud Foundation (VCF) — Private Cloud Platform", + "description": "Integrated full-stack private cloud (compute, storage, network, management, automation) for running and modernizing workloads on-prem and at the edge.", + "businessProblems": [ + "Public-cloud cost overruns driving repatriation", + "Fragmented, hard-to-operate virtualization estate", + "Need a consistent private-cloud operating model", + "Data sovereignty / control requirements" + ], + "buyingSignals": [ + "Executive posts about cloud cost, repatriation, or sovereignty", + "Mentions of data center modernization or private cloud", + "Hiring cloud platform / virtualization architects", + "Renewal/transformation of data center strategy" + ], + "antiSignals": ["Already owns VCF per CRM", "Committed to all-public-cloud with no on-prem"], + "targetBuyer": { "titles": ["CIO", "VP Infrastructure", "Chief Architect"], "departments": ["IT", "Infrastructure"] }, + "keywords": ["vcf", "cloud foundation", "private cloud", "virtualization", "data center", "repatriation", "sovereignty", "sddc", "modernization"] + }, + { + "id": "cap_vmw_vsphere", + "grouping": "VMware by Broadcom", + "category": "Compute", + "name": "VMware vSphere Foundation — Compute Virtualization", + "description": "Industry-standard server virtualization and the compute foundation for enterprise workloads, with integrated management.", + "businessProblems": [ + "Aging or unsupported virtualization infrastructure", + "Underutilized hardware and server sprawl", + "Need a reliable base for app modernization" + ], + "buyingSignals": [ + "Hardware refresh or data center consolidation initiatives", + "Hiring virtualization / infrastructure engineers", + "Mentions of hypervisor or platform standardization" + ], + "antiSignals": ["Already owns vSphere Foundation/VCF per CRM"], + "targetBuyer": { "titles": ["VP Infrastructure", "Director IT", "Chief Architect"], "departments": ["IT", "Infrastructure"] }, + "keywords": ["vsphere", "virtualization", "hypervisor", "compute", "esxi", "vcenter", "consolidation", "data center"] + }, + { + "id": "cap_vmw_vsan", + "grouping": "VMware by Broadcom", + "category": "Storage", + "name": "VMware vSAN — Hyperconverged Storage", + "description": "Software-defined, hyperconverged storage integrated with vSphere to simplify and scale storage without dedicated SAN.", + "businessProblems": [ + "Costly, complex traditional SAN storage", + "Storage scaling and refresh challenges", + "Desire to simplify with hyperconverged infrastructure" + ], + "buyingSignals": [ + "Storage refresh or HCI initiatives", + "Mentions of reducing storage cost/complexity", + "Data center modernization or edge buildout" + ], + "antiSignals": ["Already owns vSAN per CRM", "Standardized on a competing HCI/storage platform"], + "targetBuyer": { "titles": ["Director Storage", "VP Infrastructure"], "departments": ["IT", "Infrastructure"] }, + "keywords": ["vsan", "hci", "hyperconverged", "storage", "software-defined storage", "san", "modernization"] + }, + { + "id": "cap_vmw_nsx", + "grouping": "VMware by Broadcom", + "category": "Networking & Security", + "name": "VMware NSX & vDefend — Network Virtualization & Lateral Security", + "description": "Software-defined networking with micro-segmentation, distributed firewall, and advanced threat prevention for zero-trust lateral security.", + "businessProblems": [ + "Flat networks enabling lateral attacker movement", + "Manual, slow network provisioning", + "Need micro-segmentation for zero-trust" + ], + "buyingSignals": [ + "Executive posts about zero-trust or ransomware resilience", + "Mentions of micro-segmentation or network security modernization", + "Breach or ransomware incident disclosed", + "Hiring network security engineers" + ], + "antiSignals": ["Already owns NSX/vDefend per CRM"], + "targetBuyer": { "titles": ["CISO", "Director Network", "VP Infrastructure"], "departments": ["Security", "Network"] }, + "keywords": ["nsx", "vdefend", "network virtualization", "micro-segmentation", "distributed firewall", "zero trust", "ransomware", "lateral security", "sdn"] + }, + { + "id": "cap_vmw_avi", + "grouping": "VMware by Broadcom", + "category": "Networking", + "name": "VMware Avi Load Balancer — Software Load Balancing & ADC", + "description": "Software-defined, elastic load balancing and application delivery with analytics, across data center and cloud.", + "businessProblems": [ + "Costly, rigid hardware load balancers", + "No application traffic analytics", + "Scaling app delivery across hybrid/multi-cloud" + ], + "buyingSignals": [ + "Hardware load-balancer refresh or F5 migration mentions", + "Mentions of multi-cloud app delivery or autoscaling", + "Hiring platform/network engineers" + ], + "antiSignals": ["Already owns Avi per CRM"], + "targetBuyer": { "titles": ["Director Network", "VP Infrastructure", "Chief Architect"], "departments": ["IT", "Network"] }, + "keywords": ["avi", "load balancer", "adc", "application delivery", "load balancing", "multi-cloud", "autoscaling", "f5"] + }, + { + "id": "cap_vmw_aria", + "grouping": "VMware by Broadcom", + "category": "Cloud Management", + "name": "VCF Operations & Automation (Aria) — Cloud Management", + "description": "Cloud operations, automation, and self-service (formerly Aria/vRealize) for performance, cost, capacity, and provisioning across the private cloud.", + "businessProblems": [ + "Manual provisioning and slow infrastructure delivery", + "No unified visibility into cost, capacity, performance", + "Cloud sprawl without governance" + ], + "buyingSignals": [ + "Mentions of self-service infrastructure or cloud cost governance", + "Hiring cloud ops / platform engineering roles", + "Private-cloud or VCF adoption initiatives" + ], + "antiSignals": ["Already owns Aria/VCF Operations per CRM"], + "targetBuyer": { "titles": ["VP Infrastructure", "Head of Cloud Platform", "CIO"], "departments": ["IT", "Infrastructure"] }, + "keywords": ["aria", "vcf operations", "vcf automation", "cloud management", "self-service", "provisioning", "cost", "capacity", "vrealize"] + }, + { + "id": "cap_vmw_tanzu", + "grouping": "VMware by Broadcom", + "category": "Application Platform", + "name": "VMware Tanzu — Kubernetes & Application Platform", + "description": "Kubernetes runtime (VKS) and application platform for building, running, and managing modern containerized apps on VCF.", + "businessProblems": [ + "Container/Kubernetes complexity and inconsistent platforms", + "Slow path from code to production for cloud-native apps", + "Need consistent app platform across clouds" + ], + "buyingSignals": [ + "Hiring Kubernetes, platform engineering, or cloud-native developers", + "Mentions of containerization, microservices, or developer platform", + "App modernization or replatforming initiatives" + ], + "antiSignals": ["Already owns Tanzu per CRM", "Standardized on a competing Kubernetes platform"], + "targetBuyer": { "titles": ["VP Engineering", "Head of Platform", "CTO"], "departments": ["Engineering", "Platform"] }, + "keywords": ["tanzu", "kubernetes", "vks", "containers", "cloud native", "platform engineering", "microservices", "app modernization"] + }, + { + "id": "cap_vmw_workspaceone", + "grouping": "VMware by Broadcom", + "category": "Digital Workspace", + "name": "VMware Workspace ONE — Unified Endpoint Management & Digital Workspace", + "description": "Manages and secures any device with unified endpoint management and a digital workspace for anywhere work.", + "businessProblems": [ + "Securing and managing a growing fleet of devices", + "Poor employee experience accessing apps across devices", + "Hybrid/remote work device sprawl" + ], + "buyingSignals": [ + "Mentions of remote/hybrid work, BYOD, or endpoint security", + "Hiring endpoint/EUC or device management roles", + "Workforce growth or device fleet expansion" + ], + "antiSignals": ["Already owns Workspace ONE per CRM", "Standardized on a competing UEM"], + "targetBuyer": { "titles": ["Director EUC", "VP IT", "CISO"], "departments": ["IT", "Security"] }, + "keywords": ["workspace one", "uem", "endpoint management", "digital workspace", "byod", "mdm", "remote work", "device"] + }, + { + "id": "cap_vmw_horizon", + "grouping": "VMware by Broadcom", + "category": "Digital Workspace", + "name": "VMware Horizon — Virtual Desktops & Apps (VDI)", + "description": "Delivers virtual desktops and applications to any device, enabling secure remote work and simplified desktop management.", + "businessProblems": [ + "Securing remote access to desktops and apps", + "High cost/complexity of managing physical desktops", + "Need to rapidly onboard contractors or remote staff" + ], + "buyingSignals": [ + "Mentions of remote work, contractor onboarding, or VDI", + "Hiring EUC/VDI engineers", + "M&A or rapid headcount growth requiring fast provisioning" + ], + "antiSignals": ["Already owns Horizon per CRM", "Standardized on a competing VDI platform"], + "targetBuyer": { "titles": ["Director EUC", "VP IT"], "departments": ["IT"] }, + "keywords": ["horizon", "vdi", "virtual desktop", "daas", "remote work", "euc", "desktop virtualization", "contractor"] + }, + { + "id": "cap_vmw_live_recovery", + "grouping": "VMware by Broadcom", + "category": "Resilience / DR", + "name": "VMware Live Recovery — Disaster & Ransomware Recovery", + "description": "Disaster recovery and ransomware recovery (Site Recovery + Live Site Recovery) to protect and rapidly restore workloads.", + "businessProblems": [ + "Inadequate or untested disaster recovery", + "Ransomware exposure with slow recovery", + "Regulatory/board pressure on resilience" + ], + "buyingSignals": [ + "Executive posts about resilience, ransomware, or business continuity", + "Ransomware or outage incident disclosed", + "Regulatory resilience requirements (e.g., DORA) mentioned", + "Hiring resilience / DR roles" + ], + "antiSignals": ["Already owns Live Recovery/SRM per CRM"], + "targetBuyer": { "titles": ["CISO", "VP Infrastructure", "Head of Resilience"], "departments": ["Security", "IT"] }, + "keywords": ["live recovery", "disaster recovery", "ransomware", "business continuity", "srm", "resilience", "dora", "backup"] + }, + { + "id": "cap_vmw_hcx", + "grouping": "VMware by Broadcom", + "category": "Migration", + "name": "VMware HCX — Workload Migration & Mobility", + "description": "Large-scale application mobility and migration across data centers and clouds with minimal downtime.", + "businessProblems": [ + "Risky, slow data center migrations", + "Need to evacuate or consolidate data centers", + "Cloud migration with downtime constraints" + ], + "buyingSignals": [ + "Mentions of data center exit, consolidation, or cloud migration", + "M&A requiring workload consolidation", + "Lease expiry or facility move driving migration" + ], + "antiSignals": ["Already owns HCX per CRM"], + "targetBuyer": { "titles": ["VP Infrastructure", "Chief Architect", "Director IT"], "departments": ["IT", "Infrastructure"] }, + "keywords": ["hcx", "migration", "workload mobility", "data center exit", "consolidation", "cloud migration", "lift and shift"] + }, + { + "id": "cap_vmw_private_ai", + "grouping": "VMware by Broadcom", + "category": "AI Infrastructure", + "name": "VMware Private AI Foundation (with NVIDIA)", + "description": "Run and govern generative AI and ML workloads on-prem with GPU optimization, keeping data private and controlled.", + "businessProblems": [ + "Need to run AI on private data without exposing it to public clouds", + "GPU cost and utilization challenges", + "Governance and control over AI infrastructure" + ], + "buyingSignals": [ + "Executive posts about generative AI, data privacy, or GPU strategy", + "Hiring ML/AI platform or MLOps engineers", + "Mentions of on-prem AI, RAG, or private LLM initiatives" + ], + "antiSignals": ["Already owns Private AI Foundation per CRM"], + "targetBuyer": { "titles": ["CTO", "Head of AI/ML Platform", "VP Infrastructure"], "departments": ["Engineering", "IT"] }, + "keywords": ["private ai", "generative ai", "gpu", "nvidia", "mlops", "llm", "on-prem ai", "data privacy", "machine learning"] + } + ], + "bundleSignals": [ + { + "capabilityIds": ["cap_vmw_vcf", "cap_vmw_nsx", "cap_vmw_aria"], + "description": "Data center modernization / cloud repatriation signals → pitch full VCF stack (compute + network security + cloud management)." + }, + { + "capabilityIds": ["cap_iam_siteminder", "cap_iam_vip", "cap_iam_pam"], + "description": "Zero-trust or audit-finding signals → pitch the Symantec identity bundle (access mgmt + MFA + privileged access)." + }, + { + "capabilityIds": ["cap_mf_endevor", "cap_mf_brightside"], + "description": "Mainframe modernization + next-gen talent signals → pitch Endevor + Brightside DevOps modernization together." + }, + { + "capabilityIds": ["cap_es_rally", "cap_es_clarity", "cap_es_valueops_insights"], + "description": "Agile/transformation signals → pitch the ValueOps VSM platform (Rally + Clarity + Insights)." + }, + { + "capabilityIds": ["cap_vmw_live_recovery", "cap_vmw_nsx"], + "description": "Ransomware/resilience signals → pitch Live Recovery + NSX/vDefend lateral security together." + } + ], + "globalAntiSignals": [ + "Competitor displacement win logged in CRM within the last 90 days", + "Account flagged as actively divesting the relevant business unit" + ] +} diff --git a/sales-agent/data/capabilities.placeholder.json b/sales-agent/data/capabilities.placeholder.json new file mode 100644 index 0000000..68e09f2 --- /dev/null +++ b/sales-agent/data/capabilities.placeholder.json @@ -0,0 +1,144 @@ +{ + "version": "0.1.0-mvp", + "lastUpdated": "2026-06-22", + "capabilities": [ + { + "id": "cap_financial_close", + "name": "Automated Financial Close Module", + "description": "Automates month-end and quarter-end close, reducing cycle time and manual reconciliation errors across multiple entities.", + "businessProblems": [ + "Manual financial close taking 10+ days", + "Audit findings related to reconciliation errors", + "Controller or CFO unable to scale without adding headcount", + "Recent ERP migration or acquisition creating reporting gaps" + ], + "buyingSignals": [ + "Hiring for financial reporting or accounting manager roles", + "CFO or Controller posts about process improvement or modernization", + "Press release about acquisition (new entity to consolidate)", + "Mention of close process or reconciliation challenges in earnings calls", + "Job descriptions mentioning reduce close cycle time or multi-entity consolidation" + ], + "antiSignals": [ + "Already purchased this module per CRM", + "Recent competitor win logged in CRM" + ], + "targetBuyer": { + "titles": ["CFO", "Controller", "VP Finance", "Head of Accounting"], + "departments": ["Finance", "Accounting"] + }, + "keywords": ["close", "reconciliation", "consolidation", "month-end", "audit", "ERP", "controller", "multi-entity", "acquisition"] + }, + { + "id": "cap_workflow_automation", + "name": "Workflow Automation Suite", + "description": "Eliminates manual handoffs across operational processes, automating repetitive review-and-approve workflows end to end.", + "businessProblems": [ + "Manual handoffs between departments slowing throughput", + "Headcount growth required just to keep up with volume", + "Inconsistent process execution and error rates", + "Post-merger process fragmentation" + ], + "buyingSignals": [ + "Executive posts about eliminating manual work or automation", + "Hiring operations or automation roles", + "Merger or rapid growth increasing transaction volume", + "Job descriptions mentioning reduce manual intervention or automate workflows" + ], + "antiSignals": [ + "Already purchased automation suite per CRM", + "Just signed with a competing automation platform" + ], + "targetBuyer": { + "titles": ["COO", "VP Operations", "Director of Operations", "Head of RevOps"], + "departments": ["Operations", "Revenue Operations"] + }, + "keywords": ["automation", "manual", "handoff", "workflow", "operations", "throughput", "prior authorization", "claims", "process"] + }, + { + "id": "cap_analytics_platform", + "name": "Unified Analytics & Reporting Platform", + "description": "Consolidates fragmented data sources into a single reporting layer with self-serve dashboards and governed metrics.", + "businessProblems": [ + "Data scattered across systems with no single source of truth", + "Leadership lacks real-time visibility into KPIs", + "Analysts spend more time wrangling data than analyzing it", + "Reporting gaps after a system migration or merger" + ], + "buyingSignals": [ + "Hiring data analysts, BI, or analytics engineering roles", + "Executive posts about data-driven decisions or visibility", + "Mentions of building a data warehouse or single source of truth", + "Job descriptions mentioning consolidate reporting or dashboards" + ], + "antiSignals": [ + "Already purchased analytics platform per CRM", + "Standardized on a competing BI tool recently" + ], + "targetBuyer": { + "titles": ["CDO", "VP Data", "Head of Analytics", "CIO"], + "departments": ["Data", "IT", "Analytics"] + }, + "keywords": ["analytics", "reporting", "dashboard", "data warehouse", "BI", "visibility", "single source of truth", "KPI", "metrics"] + }, + { + "id": "cap_compliance_suite", + "name": "Compliance & Audit Management Suite", + "description": "Centralizes controls, evidence collection, and audit workflows to reduce compliance risk and audit prep effort.", + "businessProblems": [ + "Manual, spreadsheet-driven audit preparation", + "Expanding regulatory scope after entering new markets", + "Audit findings or material weaknesses disclosed", + "Compliance team cannot scale with the business" + ], + "buyingSignals": [ + "Hiring compliance, internal audit, or GRC roles", + "Regulatory filing disclosing control weaknesses", + "Press release about entering regulated markets", + "Executive posts about risk, controls, or governance" + ], + "antiSignals": [ + "Already purchased compliance suite per CRM" + ], + "targetBuyer": { + "titles": ["Chief Risk Officer", "VP Compliance", "Head of Internal Audit", "General Counsel"], + "departments": ["Risk", "Compliance", "Legal"] + }, + "keywords": ["compliance", "audit", "controls", "risk", "regulatory", "governance", "GRC", "material weakness", "SOX"] + }, + { + "id": "cap_revenue_intelligence", + "name": "Revenue Intelligence & Forecasting", + "description": "Applies AI to pipeline and activity data to improve forecast accuracy and surface at-risk deals.", + "businessProblems": [ + "Forecast accuracy is poor and reactive", + "Sales leaders lack visibility into deal health", + "Rapid sales-team growth straining forecasting process", + "CRM data quality undermining planning" + ], + "buyingSignals": [ + "Hiring RevOps, sales operations, or sales enablement roles", + "CRO or VP Sales posts about forecast accuracy or pipeline", + "Funding round driving aggressive sales scaling", + "Job descriptions mentioning forecasting or pipeline management" + ], + "antiSignals": [ + "Already purchased revenue intelligence per CRM" + ], + "targetBuyer": { + "titles": ["CRO", "VP Sales", "Head of RevOps", "VP Sales Operations"], + "departments": ["Sales", "Revenue Operations"] + }, + "keywords": ["forecast", "pipeline", "revenue", "sales", "RevOps", "quota", "deal", "funding", "scaling"] + } + ], + "bundleSignals": [ + { + "capabilityIds": ["cap_financial_close", "cap_workflow_automation"], + "description": "Post-acquisition consolidation + manual handoff pain often pairs close automation with workflow automation." + } + ], + "globalAntiSignals": [ + "Competitor X displacement win logged in CRM within 90 days" + ] +} diff --git a/sales-agent/data/fixtures.json b/sales-agent/data/fixtures.json new file mode 100644 index 0000000..dcad8fb --- /dev/null +++ b/sales-agent/data/fixtures.json @@ -0,0 +1,97 @@ +{ + "_comment": "PLACEHOLDER harvested signals (v1). Production: Proxycurl/Apify (linkedin), Exa.ai/NewsAPI (news), JSearch (jobs), SEC EDGAR (filings). Signals here are written to exercise the Broadcom/VMware capability library.", + "acct_atlas_bank": { + "linkedin": [ + { + "author": { "name": "Diane Okafor", "title": "CIO", "seniority": "C-suite" }, + "content": "Our public cloud bill has nearly doubled in two years. We're taking a hard look at repatriating core workloads to a modern private cloud and standardizing our data center operating model. Sovereignty and cost are both driving this.", + "engagement": { "likes": 274, "comments": 53 }, + "publishedAt": "2026-06-20T14:22:00Z", + "url": "https://linkedin.com/posts/dokafor-atlas-1" + }, + { + "author": { "name": "Raj Patel", "title": "CISO", "seniority": "C-suite" }, + "content": "Zero-trust is no longer optional for us. After our last audit we're prioritizing least-privilege access, MFA everywhere, and getting privileged accounts under control across the enterprise.", + "engagement": { "likes": 119, "comments": 27 }, + "publishedAt": "2026-06-18T10:00:00Z", + "url": "https://linkedin.com/posts/rpatel-atlas-1" + } + ], + "news": [ + { + "title": "Atlas Financial Group to modernize mainframe and adopt API-first core", + "content": "Atlas Financial announced a multi-year initiative to modernize its mainframe applications, exposing core banking services via APIs and bringing next-generation developer tooling to its z/OS teams.", + "publishedAt": "2026-06-12T09:00:00Z", + "url": "https://finextra.com/atlas-mainframe-modernization" + } + ], + "jobs": [ + { + "title": "Director, Identity & Access Management", + "content": "Lead our zero-trust identity program: SSO, MFA, and privileged access management across cloud and on-prem. Drive access governance and recertification to close audit findings.", + "count": 2, + "publishedAt": "2026-06-15T00:00:00Z", + "url": "https://jobs.atlasfinancial.com/iam-director" + } + ], + "sec_filing": [] + }, + "acct_summit_insure": { + "linkedin": [ + { + "author": { "name": "Tom Reyes", "title": "VP Infrastructure", "seniority": "VP" }, + "content": "The ransomware wave hitting our industry has the board's full attention. We're investing in resilience: tested disaster recovery, rapid ransomware recovery, and micro-segmentation to stop lateral movement. DORA timelines aren't helping the stress levels.", + "engagement": { "likes": 88, "comments": 19 }, + "publishedAt": "2026-06-21T11:00:00Z", + "url": "https://linkedin.com/posts/treyes-summit-1" + } + ], + "news": [ + { + "title": "Summit Mutual launches enterprise agile transformation", + "content": "Summit Mutual Insurance kicked off an enterprise-wide agile transformation, adopting SAFe and reorganizing IT around product value streams to speed delivery of digital insurance products.", + "publishedAt": "2026-06-17T09:00:00Z", + "url": "https://insurancejournal.com/summit-agile" + } + ], + "jobs": [ + { + "title": "Release Train Engineer / Agile Coach", + "content": "Scale agile across the enterprise. Help teams adopt SAFe, improve flow, and connect strategy to delivery as part of our transformation.", + "count": 3, + "publishedAt": "2026-06-14T00:00:00Z", + "url": "https://jobs.summitmutual.com/rte" + } + ], + "sec_filing": [] + }, + "acct_brightpath": { + "linkedin": [ + { + "author": { "name": "Alex Chen", "title": "VP Engineering", "seniority": "VP" }, + "content": "Scaling pains are real. Our last outage cost us dearly and root-cause took hours across a dozen disconnected monitoring tools. We need real observability and SRE discipline before the next one. Also drowning in ungoverned APIs as we add partners.", + "engagement": { "likes": 201, "comments": 64 }, + "publishedAt": "2026-06-21T16:00:00Z", + "url": "https://linkedin.com/posts/achen-brightpath-1" + } + ], + "news": [], + "jobs": [ + { + "title": "Site Reliability Engineer (Observability)", + "content": "Build our observability practice: full-stack application performance monitoring, reduce MTTR, and tame alert noise as we scale.", + "count": 2, + "publishedAt": "2026-06-20T00:00:00Z", + "url": "https://jobs.brightpath.io/sre" + }, + { + "title": "Platform Engineer, API & Integration", + "content": "Own our API gateway and developer portal. Govern API sprawl and onboard partners securely as our ecosystem grows.", + "count": 1, + "publishedAt": "2026-06-19T00:00:00Z", + "url": "https://jobs.brightpath.io/api-platform" + } + ], + "sec_filing": [] + } +} diff --git a/sales-agent/run.py b/sales-agent/run.py new file mode 100644 index 0000000..4ada49e --- /dev/null +++ b/sales-agent/run.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""CLI entrypoint for the Sales Prospecting Agent Loop (MVP). + +Commands: + python run.py loop [--account ID] [--tier strategic|enterprise|growth] + python run.py digest [--rep rep_sarah] + python run.py feedback --brief ID --action act_on|snooze|reject|won|lost [--note "..."] + python run.py recalibrate + python run.py accounts + python run.py briefs +""" +from __future__ import annotations + +import argparse +import sys + +from src import config, pipeline +from src.connectors import crm, exa +from src.delivery import digest as digest_mod +from src.memory import store, feedback + + +def cmd_loop(args): + if args.no_domain_filter: + config.EXA_DOMAIN_FILTER = False # per-run override + pipeline.run_all(tier=args.tier, account_id=args.account) + print("\n--- digest preview ---") + print(digest_mod.write_digest()) + + +def cmd_digest(args): + print(digest_mod.write_digest(rep_id=args.rep)) + + +def cmd_feedback(args): + entry = feedback.record_feedback(args.brief, args.action, args.note or "") + print(f"Recorded '{args.action}' on brief {args.brief}. " + f"New status: {store.get_brief(args.brief)['status']}") + + +def cmd_recalibrate(args): + weights = feedback.recalibrate() + if not weights: + print("Not enough feedback yet (need >=3 samples per capability/tier/signal combo).") + return + print("Updated scoring weights:") + for k, v in weights.items(): + print(f" {k}: weight={v['weight']} (n={v['samples']})") + + +def cmd_accounts(args): + for a in crm.load_accounts(): + print(f" {a['accountId']:18} {a['accountTier']:10} {a['accountName']:28} " + f"rep={a['assignedRep']['name']} has={a.get('currentProducts') or '[]'}") + + +def cmd_briefs(args): + briefs = sorted(store.load_briefs(), key=lambda b: b["composite_score"], reverse=True) + if not briefs: + print("No briefs yet. Run: python run.py loop") + return + for b in briefs: + print(f" {b['id']} [{b['priority']:8}] score={b['composite_score']:<5} " + f"status={b['status']:9} {b['account_name']} — {b['capability_name']}") + + +def cmd_exa_usage(args): + st = exa.usage_status() + print("Exa.ai request budget") + print(f" enabled: {st['enabled']}") + print(f" month: {st['month']}") + print(f" used this month: {st['used_this_month']}") + print(f" monthly cap: {st['monthly_cap']}") + print(f" monthly remaining: {st['monthly_remaining']}") + print(f" per-run cap: {st['per_run_cap']}") + if not st["enabled"]: + print(" (Exa disabled — set EXA_API_KEY in .env to enable; using fixtures)") + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Sales Prospecting Agent Loop (MVP)") + sub = parser.add_subparsers(dest="command", required=True) + + p_loop = sub.add_parser("loop", help="Run the harvest->deliver loop") + p_loop.add_argument("--account", help="single account id") + p_loop.add_argument("--tier", choices=["strategic", "enterprise", "growth"]) + p_loop.add_argument("--no-domain-filter", action="store_true", + help="include broad third-party news instead of scoping Exa to the account domain") + p_loop.set_defaults(func=cmd_loop) + + p_dig = sub.add_parser("digest", help="Print the daily intelligence digest") + p_dig.add_argument("--rep", help="filter to a rep id") + p_dig.set_defaults(func=cmd_digest) + + p_fb = sub.add_parser("feedback", help="Record rep feedback on a brief") + p_fb.add_argument("--brief", required=True) + p_fb.add_argument("--action", required=True, + choices=["act_on", "snooze", "reject", "won", "lost"]) + p_fb.add_argument("--note", default="") + p_fb.set_defaults(func=cmd_feedback) + + p_re = sub.add_parser("recalibrate", help="Recompute feedback weights (weekly job)") + p_re.set_defaults(func=cmd_recalibrate) + + sub.add_parser("accounts", help="List target accounts").set_defaults(func=cmd_accounts) + sub.add_parser("briefs", help="List all generated briefs").set_defaults(func=cmd_briefs) + sub.add_parser("exa-usage", help="Show Exa.ai request budget").set_defaults(func=cmd_exa_usage) + + args = parser.parse_args(argv) + config.ensure_dirs() + args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sales-agent/src/__init__.py b/sales-agent/src/__init__.py new file mode 100644 index 0000000..1276447 --- /dev/null +++ b/sales-agent/src/__init__.py @@ -0,0 +1 @@ +"""Enterprise Sales Prospecting Agent Loop — MVP (v1, placeholder assumptions).""" diff --git a/sales-agent/src/agents/__init__.py b/sales-agent/src/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sales-agent/src/agents/harvester.py b/sales-agent/src/agents/harvester.py new file mode 100644 index 0000000..0a3cdf9 --- /dev/null +++ b/sales-agent/src/agents/harvester.py @@ -0,0 +1,16 @@ +"""Phase 1: Signal Harvesting. Fans out across all source connectors per account.""" +from __future__ import annotations + +from ..connectors import linkedin, news, jobs +from ..types import RawSignal + + +def run_harvesters(account: dict) -> list[RawSignal]: + """Equivalent of Promise.allSettled across source harvesters.""" + raw: list[RawSignal] = [] + for harvester in (linkedin.harvest, news.harvest, jobs.harvest): + try: + raw.extend(harvester(account)) + except Exception as e: # one bad source shouldn't kill the run + print(f" [harvest] {harvester.__module__} failed: {e}") + return raw diff --git a/sales-agent/src/agents/normalizer.py b/sales-agent/src/agents/normalizer.py new file mode 100644 index 0000000..c766772 --- /dev/null +++ b/sales-agent/src/agents/normalizer.py @@ -0,0 +1,22 @@ +"""Phase 2: Normalization + deduplication. + +Hash dedup (exact) is implemented. Semantic dedup (embeddings/cosine) is noted +as a v2 hook — the MVP relies on content-fingerprint hashing only. +""" +from __future__ import annotations + +from ..types import RawSignal, NormalizedSignal +from ..memory import store + + +def normalize_and_dedup(raw_signals: list[RawSignal], account: dict, mem: dict) -> list[NormalizedSignal]: + seen = store.seen_hashes(mem) + out: list[NormalizedSignal] = [] + fresh_ids: set[str] = set() + for raw in raw_signals: + norm = NormalizedSignal.from_raw(raw) + if norm.id in seen or norm.id in fresh_ids: + continue # exact duplicate, already processed in a prior run + fresh_ids.add(norm.id) + out.append(norm) + return out diff --git a/sales-agent/src/agents/relevance.py b/sales-agent/src/agents/relevance.py new file mode 100644 index 0000000..87a5947 --- /dev/null +++ b/sales-agent/src/agents/relevance.py @@ -0,0 +1,115 @@ +"""Phase 3: Relevance filtering against the Capability Library (LLM reasoning). + +Each signal is pre-filtered to candidate capabilities (keyword ANN stand-in), +then evaluated by the LLM. A deterministic mock path runs when no API key is set. +""" +from __future__ import annotations + +from .. import capabilities, llm +from ..types import NormalizedSignal, RelevanceResult + +SYSTEM = """You are an account intelligence analyst for an enterprise software company. +Your job: evaluate whether a business signal indicates a genuine cross-sell opportunity +for one of our product capabilities. Be skeptical — only flag signals grounded in a real, +specific business need that a listed capability directly addresses. Return JSON only.""" + +USER_TMPL = """ACCOUNT CONTEXT +Company: {account_name} +Current Products: {current_products} +Account Tier: {account_tier} + +SIGNAL +{content} +Source: {source} | Type: {signal_type} | Date: {published_at} + +CANDIDATE CAPABILITIES +{capability_summary} + +INSTRUCTIONS +1. Determine if this signal indicates a business need one or more candidate capabilities directly addresses. +2. If yes, list the specific capability id(s) and explain WHY (cite the signal). +3. If the account ALREADY has that capability (see Current Products), set "isExpansion": true. +4. Assign signalStrength: strong | moderate | weak. +5. Identify the ideal contact to approach (title + department). +6. If no genuine relevance exists, set "relevant": false. + +Return ONLY this JSON: +{{"relevant": bool, "capabilityIds": [str], "rationale": str, "signalStrength": "strong|moderate|weak", +"isExpansion": bool, "idealContact": {{"title": str, "department": str}}, "suggestedAngle": str}}""" + + +def _mock_evaluate(signal: NormalizedSignal, candidates: list[dict], account: dict) -> dict: + """Deterministic stand-in for the LLM: keyword overlap + seniority heuristics.""" + if not candidates: + return {"relevant": False, "capabilityIds": [], "rationale": "No candidate capability matched.", + "signalStrength": "weak", "isExpansion": False, + "idealContact": {"title": "", "department": ""}, "suggestedAngle": ""} + top = candidates[0] + seniority = (signal.author or {}).get("seniority", "") + strength = "strong" if (seniority == "C-suite" or "[" in signal.content[:3]) else "moderate" + if signal.source == "jobs" and signal.signal_type == "hiring_surge": + strength = "strong" + is_expansion = top["id"] in account.get("currentProducts", []) + return { + "relevant": True, + "capabilityIds": [top["id"]], + "rationale": ( + f"Signal references themes central to {top['name']} " + f"(e.g. {', '.join(top['keywords'][:3])}). Source={signal.source}, " + f"author seniority={seniority or 'n/a'}." + ), + "signalStrength": strength, + "isExpansion": is_expansion, + "idealContact": { + "title": top["targetBuyer"]["titles"][0], + "department": top["targetBuyer"]["departments"][0], + }, + "suggestedAngle": top["businessProblems"][0], + } + + +def evaluate_signal(signal: NormalizedSignal, account: dict) -> RelevanceResult | None: + candidates = capabilities.prefilter(signal.content) + if not candidates: + return None + + if llm.is_mock(): + data = _mock_evaluate(signal, candidates, account) + else: + user = USER_TMPL.format( + account_name=account["accountName"], + current_products=", ".join(account.get("currentProducts", [])) or "none", + account_tier=account["accountTier"], + content=signal.content, + source=signal.source, + signal_type=signal.signal_type, + published_at=signal.published_at, + capability_summary=capabilities.library_summary_for_prompt(candidates), + ) + try: + data = llm.call_json(SYSTEM, user) + except llm.LLMError as e: + print(f" [relevance] LLM error, falling back to mock: {e}") + data = _mock_evaluate(signal, candidates, account) + + if not data.get("relevant"): + return None + return RelevanceResult( + signal_id=signal.id, + relevant=True, + capability_ids=data.get("capabilityIds", []), + rationale=data.get("rationale", ""), + signal_strength=data.get("signalStrength", "weak"), + is_expansion=bool(data.get("isExpansion", False)), + ideal_contact=data.get("idealContact", {}), + suggested_angle=data.get("suggestedAngle", ""), + ) + + +def filter_for_relevance(signals: list[NormalizedSignal], account: dict) -> list[tuple[NormalizedSignal, RelevanceResult]]: + out = [] + for s in signals: + res = evaluate_signal(s, account) + if res: + out.append((s, res)) + return out diff --git a/sales-agent/src/agents/scorer.py b/sales-agent/src/agents/scorer.py new file mode 100644 index 0000000..285e42d --- /dev/null +++ b/sales-agent/src/agents/scorer.py @@ -0,0 +1,95 @@ +"""Phase 5: Composite scoring & prioritization. + +compositeScore = signalStrength*0.30 + llmConfidence*0.20 + accountTier*0.20 + + buyerSeniority*0.15 + timingUrgency*0.10 + repFeedbackWeight*0.05 +All sub-scores are normalized to 0..1. (The design's base formula is extended +here with an llmConfidence term so the reasoning model's own confidence shapes +ranking.) A separate hard floor (CONFIDENCE_FLOOR) demotes briefs the model +doubts, regardless of composite score. +""" +from __future__ import annotations + +from .. import config +from ..types import OpportunityBrief, now_utc, parse_dt +from ..memory import feedback + +_TIER = {"strategic": 1.0, "enterprise": 0.66, "growth": 0.33} +_SENIORITY = {"c-suite": 1.0, "evp": 1.0, "svp": 0.85, "vp": 0.7, + "director": 0.55, "manager": 0.35} + + +def _signal_strength_score(brief: OpportunityBrief) -> float: + strengths = [s.get("strength") for s in brief.signals] + strong = strengths.count("strong") + moderate = strengths.count("moderate") + if strong >= 2: + return 1.0 + if strong == 1 or moderate >= 2: + return 0.7 + return 0.4 + + +def _seniority_score(brief: OpportunityBrief) -> float: + best = 0.0 + for s in brief.signals: + author = s.get("author") or {} + sen = (author.get("seniority") or "").lower() + best = max(best, _SENIORITY.get(sen, 0.0)) + title = (author.get("title") or "").lower() + for key, val in _SENIORITY.items(): + if key in title: + best = max(best, val) + return best or 0.4 + + +def _timing_score(brief: OpportunityBrief) -> float: + newest = None + for s in brief.signals: + try: + dt = parse_dt(s["published_at"]) + except Exception: + continue + if newest is None or dt > newest: + newest = dt + if newest is None: + return 0.5 + age = (now_utc() - newest).days + if age < 7: + return 1.0 + if age <= 21: + return 0.66 + return 0.33 + + +def score_and_prioritize(briefs: list[OpportunityBrief], account: dict) -> list[OpportunityBrief]: + tier = account["accountTier"] + for b in briefs: + s_strength = _signal_strength_score(b) + s_confidence = max(0.0, min(1.0, b.confidence_score)) + s_tier = _TIER.get(tier, 0.33) + s_seniority = _seniority_score(b) + s_timing = _timing_score(b) + s_feedback = feedback.feedback_weight( + b.capability_id, tier, [s.get("signal_type") for s in b.signals] + ) + + composite = ( + s_strength * 0.30 + + s_confidence * 0.20 + + s_tier * 0.20 + + s_seniority * 0.15 + + s_timing * 0.10 + + s_feedback * 0.05 + ) + b.composite_score = round(composite, 3) + # Hard gate: the model's own low confidence keeps a brief out of delivery. + if s_confidence < config.CONFIDENCE_FLOOR: + b.priority = "low" + elif composite >= config.PRIORITY_THRESHOLD: + b.priority = "priority" + elif composite >= config.STANDARD_THRESHOLD: + b.priority = "standard" + else: + b.priority = "low" + briefs.sort(key=lambda x: x.composite_score, reverse=True) + return briefs diff --git a/sales-agent/src/agents/synthesizer.py b/sales-agent/src/agents/synthesizer.py new file mode 100644 index 0000000..9036048 --- /dev/null +++ b/sales-agent/src/agents/synthesizer.py @@ -0,0 +1,161 @@ +"""Phase 4: Opportunity synthesis. + +Groups relevant signals by capability, then produces one Opportunity Brief per +(account, capability) via a second LLM pass. Mock path builds a grounded brief +deterministically from the contributing signals. +""" +from __future__ import annotations + +from datetime import timedelta + +from .. import capabilities, llm, config +from ..types import ( + NormalizedSignal, RelevanceResult, OpportunityBrief, + now_utc, iso, fingerprint, +) + +SYSTEM = """You are synthesizing validated account-intelligence signals into a single, +actionable sales opportunity brief. Stay grounded — never over-promise beyond what the +signals say. Lead with the most compelling signal. Return JSON only.""" + +USER_TMPL = """ACCOUNT +{account_name} (tier: {account_tier}); current products: {current_products} + +TARGET CAPABILITY +{capability_name}: {capability_desc} + +VALIDATED SIGNALS +{signals_block} + +Create a brief. Return ONLY this JSON: +{{"headline": str (one sentence), +"executiveSummary": str (2-3 sentences for the rep), +"talkingPoints": [str, str, str], +"outreachHook": str (one opener for email/LinkedIn DM), +"urgencyLevel": "high|medium|low", +"confidenceScore": float 0..1}}""" + + +def _signals_block(items: list[tuple[NormalizedSignal, RelevanceResult]]) -> str: + lines = [] + for i, (s, r) in enumerate(items, 1): + author = "" + if s.author: + author = f" — {s.author.get('name')} ({s.author.get('title')})" + lines.append( + f"[{i}] {s.source}/{s.signal_type}{author} ({s.published_at[:10]})\n" + f' "{s.content[:400]}"\n' + f" relevance: {r.signal_strength} — {r.rationale}" + ) + return "\n".join(lines) + + +def _target_contacts(account: dict, ideal: dict) -> list[dict]: + contacts = list(account.get("targetContacts", [])) + # Surface the ideal-contact title even if not yet in CRM. + if ideal.get("title") and not any( + ideal["title"].lower() in (c.get("title", "").lower()) for c in contacts + ): + contacts.insert(0, {"name": "(prospect)", "title": ideal["title"], + "department": ideal.get("department", ""), "inCrm": False}) + return contacts + + +def _mock_brief_body(cap: dict, items, account) -> dict: + strong = [r for _, r in items if r.signal_strength == "strong"] + urgency = "high" if strong else ("medium" if len(items) > 1 else "low") + confidence = min(0.95, 0.45 + 0.18 * len(strong) + 0.08 * len(items)) + lead = items[0][0] + tps = [ + f"{cap['name']} directly addresses: {cap['businessProblems'][0]}.", + f"Signal evidence: {items[0][1].rationale}", + ] + if len(items) > 1: + tps.append(f"Reinforced by {len(items)-1} additional signal(s) across " + f"{', '.join(sorted({s.source for s, _ in items}))}.") + hook_who = (lead.author or {}).get("name", "there") + return { + "headline": f"{account['accountName']}: {len(items)} signal(s) point to {cap['name']}.", + "executiveSummary": ( + f"{account['accountName']} is showing intent around {cap['name'].lower()}. " + f"Lead signal: {lead.signal_type} via {lead.source}. " + f"{len(strong)} strong / {len(items)} total contributing signals." + ), + "talkingPoints": tps, + "outreachHook": ( + f"{hook_who} — saw the recent signal around " + f"{cap['keywords'][0]}. We've helped similar {account.get('industry','')} " + f"teams here. Worth 20 minutes?" + ), + "urgencyLevel": urgency, + "confidenceScore": round(confidence, 2), + } + + +def synthesize( + relevant: list[tuple[NormalizedSignal, RelevanceResult]], account: dict +) -> list[OpportunityBrief]: + # Group by capability id. + by_cap: dict[str, list[tuple[NormalizedSignal, RelevanceResult]]] = {} + for s, r in relevant: + for cap_id in r.capability_ids: + by_cap.setdefault(cap_id, []).append((s, r)) + + briefs: list[OpportunityBrief] = [] + for cap_id, items in by_cap.items(): + cap = capabilities.capability_by_id(cap_id) + if not cap: + continue + # Strongest signal first. + order = {"strong": 0, "moderate": 1, "weak": 2} + items.sort(key=lambda x: order.get(x[1].signal_strength, 3)) + + if llm.is_mock(): + body = _mock_brief_body(cap, items, account) + else: + user = USER_TMPL.format( + account_name=account["accountName"], + account_tier=account["accountTier"], + current_products=", ".join(account.get("currentProducts", [])) or "none", + capability_name=cap["name"], + capability_desc=cap["description"], + signals_block=_signals_block(items), + ) + try: + body = llm.call_json(SYSTEM, user, max_tokens=1200) + except llm.LLMError as e: + print(f" [synthesize] LLM error, falling back to mock: {e}") + body = _mock_brief_body(cap, items, account) + + created = now_utc() + ideal = items[0][1].ideal_contact + brief = OpportunityBrief( + id=fingerprint(account["accountId"], cap_id, iso(created)), + account_id=account["accountId"], + account_name=account["accountName"], + capability_id=cap_id, + capability_name=cap["name"], + headline=body.get("headline", ""), + executive_summary=body.get("executiveSummary", ""), + signal_ids=[s.id for s, _ in items], + signals=[ + { + "id": s.id, "source": s.source, "signal_type": s.signal_type, + "content": s.content, "published_at": s.published_at, "url": s.url, + "author": s.author, "strength": r.signal_strength, + } + for s, r in items + ], + talking_points=body.get("talkingPoints", []), + outreach_hook=body.get("outreachHook", ""), + target_contacts=_target_contacts(account, ideal), + urgency_level=body.get("urgencyLevel", "medium"), + confidence_score=float(body.get("confidenceScore", 0.5)), + composite_score=0.0, # filled by scorer + priority="", # filled by scorer + assigned_rep=account["assignedRep"], + created_at=iso(created), + expires_at=iso(created + timedelta(days=config.BRIEF_TTL_DAYS)), + ) + briefs.append(brief) + return briefs diff --git a/sales-agent/src/capabilities.py b/sales-agent/src/capabilities.py new file mode 100644 index 0000000..53d35fc --- /dev/null +++ b/sales-agent/src/capabilities.py @@ -0,0 +1,61 @@ +"""Capability Library loader + lightweight keyword pre-filter. + +The design uses a vector ANN pre-filter to cut LLM calls ~60%. For the MVP we +approximate that with keyword overlap scoring (no embedding service required). +The interface is the same: given a signal, return candidate capabilities. +""" +from __future__ import annotations + +import json +import re +from functools import lru_cache + +from . import config + + +@lru_cache(maxsize=1) +def load_library() -> dict: + return json.loads((config.DATA_DIR / "capabilities.json").read_text()) + + +def capability_by_id(cap_id: str) -> dict | None: + for cap in load_library()["capabilities"]: + if cap["id"] == cap_id: + return cap + return None + + +def _tokens(text: str) -> set[str]: + return set(re.findall(r"[a-z]{3,}", text.lower())) + + +def prefilter(signal_content: str, top_k: int = 3) -> list[dict]: + """Return capabilities whose keywords overlap the signal, best first. + + Stand-in for the vector ANN pre-filter in the design (section 5.2). + """ + sig_tokens = _tokens(signal_content) + scored: list[tuple[float, dict]] = [] + for cap in load_library()["capabilities"]: + kw = set(k.lower() for k in cap.get("keywords", [])) + kw_tokens: set[str] = set() + for k in kw: + kw_tokens |= _tokens(k) + overlap = len(sig_tokens & kw_tokens) + if overlap: + scored.append((overlap, cap)) + scored.sort(key=lambda x: x[0], reverse=True) + return [cap for _, cap in scored[:top_k]] + + +def library_summary_for_prompt(candidates: list[dict]) -> str: + """Compact capability descriptions for the relevance-filter prompt.""" + lines = [] + for c in candidates: + lines.append( + f"- {c['id']} | {c['name']}: {c['description']}\n" + f" Buying signals: {'; '.join(c['buyingSignals'])}\n" + f" Anti-signals: {'; '.join(c['antiSignals'])}\n" + f" Target buyer: {', '.join(c['targetBuyer']['titles'])}" + ) + return "\n".join(lines) diff --git a/sales-agent/src/config.py b/sales-agent/src/config.py new file mode 100644 index 0000000..abed59d --- /dev/null +++ b/sales-agent/src/config.py @@ -0,0 +1,103 @@ +"""Central config & paths. Loads a .env file if present (no external deps).""" +from __future__ import annotations + +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT / "data" +OUTPUT_DIR = ROOT / "output" +STATE_DIR = OUTPUT_DIR / "state" +BRIEFS_DIR = OUTPUT_DIR / "briefs" + + +def _load_dotenv() -> None: + """Minimal .env loader so the user can keep keys in sales-agent/.env.""" + env_path = ROOT / ".env" + if not env_path.exists(): + return + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key, val = key.strip(), val.strip().strip('"').strip("'") + os.environ.setdefault(key, val) + + +_load_dotenv() + +# --- LLM --- +# Provider abstraction: gemini | anthropic | mock. If LLM_PROVIDER is unset we +# auto-detect from whichever key is present (Gemini preferred), falling back to +# a deterministic mock reasoner so the loop still runs offline. +ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip() +LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-4-6") + +GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip() +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash") + +_provider = os.environ.get("LLM_PROVIDER", "").strip().lower() +if not _provider: + if GEMINI_API_KEY: + _provider = "gemini" + elif ANTHROPIC_API_KEY: + _provider = "anthropic" + else: + _provider = "mock" +# A forced USE_MOCK_LLM=1, or selecting a provider whose key is missing, => mock. +if os.environ.get("USE_MOCK_LLM", "").strip() == "1": + _provider = "mock" +elif _provider == "gemini" and not GEMINI_API_KEY: + _provider = "mock" +elif _provider == "anthropic" and not ANTHROPIC_API_KEY: + _provider = "mock" + +LLM_PROVIDER = _provider +USE_MOCK_LLM = LLM_PROVIDER == "mock" + + +def llm_label() -> str: + if LLM_PROVIDER == "mock": + return "MOCK reasoner (no LLM key)" + if LLM_PROVIDER == "gemini": + return f"Gemini ({GEMINI_MODEL})" + return f"Anthropic ({LLM_MODEL})" + +# --- Scoring thresholds (Phase 5 of the design) --- +PRIORITY_THRESHOLD = float(os.environ.get("PRIORITY_THRESHOLD", "0.72")) +STANDARD_THRESHOLD = float(os.environ.get("STANDARD_THRESHOLD", "0.45")) +# Briefs whose LLM confidence falls below this floor are demoted to "low" +# (logged, not delivered) regardless of composite score — keeps weak matches +# the model itself doubts out of the rep's queue. +CONFIDENCE_FLOOR = float(os.environ.get("CONFIDENCE_FLOOR", "0.35")) + +# --- Exa.ai news/web harvester --- +EXA_API_KEY = os.environ.get("EXA_API_KEY", "").strip() +# Enabled only when a key is present AND not explicitly turned off. +EXA_ENABLED = (os.environ.get("EXA_ENABLED", "1").strip() == "1") and bool(EXA_API_KEY) +# Hard request ceilings to protect the free plan. The connector will NOT call +# Exa once either cap is reached; the monthly counter persists across runs. +EXA_MONTHLY_REQUEST_CAP = int(os.environ.get("EXA_MONTHLY_REQUEST_CAP", "1000")) +EXA_PER_RUN_REQUEST_CAP = int(os.environ.get("EXA_PER_RUN_REQUEST_CAP", "25")) +EXA_RESULTS_PER_QUERY = int(os.environ.get("EXA_RESULTS_PER_QUERY", "5")) +EXA_LOOKBACK_DAYS = int(os.environ.get("EXA_LOOKBACK_DAYS", "45")) +# Scope news search to each account's own domain (newsroom/press/IR) to cut +# third-party ticker/analyst noise. Toggle off in .env or per-run with +# `loop --no-domain-filter` to include broad third-party coverage. +EXA_DOMAIN_FILTER = os.environ.get("EXA_DOMAIN_FILTER", "1").strip() == "1" + +# --- Dedup / suppression windows --- +SEMANTIC_DEDUP_DAYS = 90 +SAME_TYPE_SUPPRESS_DAYS = 14 + +# Per-tier harvest cadence (informational for the scheduler; the MVP runs on demand) +TIER_CADENCE_HOURS = {"strategic": 24, "enterprise": 60, "growth": 168} + +# Brief expiry (opportunities decay if not acted on) +BRIEF_TTL_DAYS = 21 + + +def ensure_dirs() -> None: + for d in (OUTPUT_DIR, STATE_DIR, BRIEFS_DIR): + d.mkdir(parents=True, exist_ok=True) diff --git a/sales-agent/src/connectors/__init__.py b/sales-agent/src/connectors/__init__.py new file mode 100644 index 0000000..a5c7c58 --- /dev/null +++ b/sales-agent/src/connectors/__init__.py @@ -0,0 +1,21 @@ +"""Source connectors. v1 reads placeholder fixtures; swap for real APIs later. + +Production mapping (from the design): + linkedin -> Proxycurl + Apify + news -> Exa.ai + NewsAPI + jobs -> JSearch (RapidAPI) / Adzuna + crm -> Salesforce / HubSpot REST + sec -> SEC EDGAR +""" +import json +from functools import lru_cache +from .. import config + + +@lru_cache(maxsize=1) +def _fixtures() -> dict: + return json.loads((config.DATA_DIR / "fixtures.json").read_text()) + + +def account_fixture(account_id: str) -> dict: + return _fixtures().get(account_id, {}) diff --git a/sales-agent/src/connectors/crm.py b/sales-agent/src/connectors/crm.py new file mode 100644 index 0000000..59974ce --- /dev/null +++ b/sales-agent/src/connectors/crm.py @@ -0,0 +1,39 @@ +"""CRM connector (v1 reads data/accounts.json -> Salesforce/HubSpot in production). + +Provides read (account context) and write-back (activity logging) stubs. +""" +from __future__ import annotations + +import json +from functools import lru_cache + +from .. import config + + +@lru_cache(maxsize=1) +def _accounts() -> list[dict]: + return json.loads((config.DATA_DIR / "accounts.json").read_text())["accounts"] + + +def load_accounts(tier: str | None = None, account_id: str | None = None) -> list[dict]: + accts = _accounts() + if account_id: + accts = [a for a in accts if a["accountId"] == account_id] + if tier: + accts = [a for a in accts if a["accountTier"] == tier] + return accts + + +def get_account(account_id: str) -> dict | None: + for a in _accounts(): + if a["accountId"] == account_id: + return a + return None + + +def log_activity(account_id: str, brief: dict) -> None: + """Write-back stub. Production: create a Salesforce/HubSpot Task + activity.""" + print( + f" [CRM] logged activity on {account_id}: " + f"\"{brief['headline']}\" -> task assigned to {brief['assigned_rep']['name']}" + ) diff --git a/sales-agent/src/connectors/exa.py b/sales-agent/src/connectors/exa.py new file mode 100644 index 0000000..5f9e0c1 --- /dev/null +++ b/sales-agent/src/connectors/exa.py @@ -0,0 +1,168 @@ +"""Exa.ai news/web harvester (real API) with hard request ceilings. + +Free-plan protection (the whole point of this module): + * A persistent MONTHLY counter in output/state/exa_usage.json, reset each + calendar month. Once EXA_MONTHLY_REQUEST_CAP is hit, no more calls are made. + * A PER-RUN counter (process-local) capped at EXA_PER_RUN_REQUEST_CAP. +A request is only counted when an HTTP response actually comes back from Exa +(i.e. a billable call). Network failures that never reach Exa are not counted. + +Returns: + list[RawSignal] on a successful call (may be empty if Exa found no news) + None when Exa is disabled, a cap is reached, or the call errored + -> signals the news harvester to fall back to fixtures. +""" +from __future__ import annotations + +import json +import urllib.request +import urllib.error +from datetime import timedelta + +from .. import config +from ..types import RawSignal, now_utc, iso + +_ENDPOINT = "https://api.exa.ai/search" + +# Process-local counter -> enforces the per-run cap (resets every CLI invocation). +_run_count = 0 + + +def _usage_path(): + return config.STATE_DIR / "exa_usage.json" + + +def _current_month() -> str: + return now_utc().strftime("%Y-%m") + + +def _load_usage() -> dict: + config.ensure_dirs() + p = _usage_path() + if p.exists(): + data = json.loads(p.read_text()) + if data.get("month") == _current_month(): + return data + # New file or a new month -> reset the counter. + return {"month": _current_month(), "count": 0} + + +def _save_usage(data: dict) -> None: + config.ensure_dirs() + _usage_path().write_text(json.dumps(data, indent=2)) + + +def usage_status() -> dict: + """Snapshot of current budget, for the CLI / startup banner.""" + u = _load_usage() + return { + "enabled": config.EXA_ENABLED, + "month": u["month"], + "used_this_month": u["count"], + "monthly_cap": config.EXA_MONTHLY_REQUEST_CAP, + "monthly_remaining": max(0, config.EXA_MONTHLY_REQUEST_CAP - u["count"]), + "per_run_used": _run_count, + "per_run_cap": config.EXA_PER_RUN_REQUEST_CAP, + } + + +def _within_caps() -> tuple[bool, str]: + if _run_count >= config.EXA_PER_RUN_REQUEST_CAP: + return False, f"per-run cap reached ({config.EXA_PER_RUN_REQUEST_CAP})" + u = _load_usage() + if u["count"] >= config.EXA_MONTHLY_REQUEST_CAP: + return False, f"monthly cap reached ({config.EXA_MONTHLY_REQUEST_CAP})" + return True, "" + + +def _count_request() -> None: + global _run_count + _run_count += 1 + u = _load_usage() + u["count"] += 1 + _save_usage(u) + + +def _build_query(account: dict, domain_scoped: bool) -> str: + name = account["accountName"] + if domain_scoped: + # Domain is already restricting results, so keep the query topical/broad. + return ( + f"{name} announcements: funding, acquisition, merger, leadership change, " + f"strategy, restructuring, product launch, hiring, earnings, partnership" + ) + # No domain restriction -> anchor harder on the company name to limit drift. + return ( + f'"{name}" company news: funding, acquisition, merger, leadership change, ' + f"strategy, restructuring, product launch, hiring, earnings, partnership" + ) + + +def harvest_news(account: dict) -> list[RawSignal] | None: + if not config.EXA_ENABLED: + return None + + ok, reason = _within_caps() + if not ok: + print(f" [exa] skipped — {reason}; staying within free plan (using fixtures)") + return None + + start = iso(now_utc() - timedelta(days=config.EXA_LOOKBACK_DAYS)) + domain = (account.get("domain") or "").strip() + domain_scoped = config.EXA_DOMAIN_FILTER and bool(domain) + payload = { + "query": _build_query(account, domain_scoped), + "type": "auto", + "numResults": config.EXA_RESULTS_PER_QUERY, + "startPublishedDate": start, + "contents": {"text": {"maxCharacters": 1000}}, + } + if domain_scoped: + payload["includeDomains"] = [domain] + req = urllib.request.Request( + _ENDPOINT, + data=json.dumps(payload).encode("utf-8"), + headers={"content-type": "application/json", "x-api-key": config.EXA_API_KEY}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + # The call reached Exa (billable) but returned an error status. + _count_request() + detail = e.read().decode("utf-8")[:200] if hasattr(e, "read") else "" + print(f" [exa] HTTP {e.code}: {detail} — falling back to fixtures") + return None + except urllib.error.URLError as e: + # Never reached Exa -> not counted. + print(f" [exa] network error ({e.reason}) — falling back to fixtures") + return None + + _count_request() # successful billable request + + results = body.get("results", []) or [] + signals: list[RawSignal] = [] + for r in results: + title = (r.get("title") or "").strip() + text = (r.get("text") or "").strip() + if not (title or text): + continue + content = f"{title}. {text}" if title else text + signals.append( + RawSignal( + account_id=account["accountId"], + source="news", + signal_type="news_article", + content=content[:1200], + published_at=r.get("publishedDate") or iso(now_utc()), + url=r.get("url", ""), + extra={"title": title, "exa_score": r.get("score"), "via": "exa", + "domain_scoped": domain_scoped}, + ) + ) + remaining = usage_status()["monthly_remaining"] + scope = f"domain={domain}" if domain_scoped else "broad web" + print(f" [exa] {len(signals)} article(s) for {account['accountName']} ({scope}) " + f"(monthly budget left: {remaining}/{config.EXA_MONTHLY_REQUEST_CAP})") + return signals diff --git a/sales-agent/src/connectors/jobs.py b/sales-agent/src/connectors/jobs.py new file mode 100644 index 0000000..68c2900 --- /dev/null +++ b/sales-agent/src/connectors/jobs.py @@ -0,0 +1,24 @@ +"""Job posting harvester (v1 placeholder -> JSearch/Adzuna in production).""" +from __future__ import annotations + +from ..types import RawSignal +from . import account_fixture + + +def harvest(account: dict) -> list[RawSignal]: + out: list[RawSignal] = [] + for item in account_fixture(account["accountId"]).get("jobs", []): + count = item.get("count", 1) + prefix = f"[{count} openings] " if count > 1 else "" + out.append( + RawSignal( + account_id=account["accountId"], + source="jobs", + signal_type="hiring_surge" if count > 1 else "job_posting", + content=f"{prefix}{item['title']}: {item['content']}", + published_at=item["publishedAt"], + url=item["url"], + extra={"title": item["title"], "count": count}, + ) + ) + return out diff --git a/sales-agent/src/connectors/linkedin.py b/sales-agent/src/connectors/linkedin.py new file mode 100644 index 0000000..c275507 --- /dev/null +++ b/sales-agent/src/connectors/linkedin.py @@ -0,0 +1,23 @@ +"""LinkedIn harvester (v1 placeholder -> Proxycurl/Apify in production).""" +from __future__ import annotations + +from ..types import RawSignal +from . import account_fixture + + +def harvest(account: dict) -> list[RawSignal]: + out: list[RawSignal] = [] + for item in account_fixture(account["accountId"]).get("linkedin", []): + out.append( + RawSignal( + account_id=account["accountId"], + source="linkedin", + signal_type="executive_post", + content=item["content"], + published_at=item["publishedAt"], + url=item["url"], + author=item.get("author"), + engagement=item.get("engagement"), + ) + ) + return out diff --git a/sales-agent/src/connectors/news.py b/sales-agent/src/connectors/news.py new file mode 100644 index 0000000..4093998 --- /dev/null +++ b/sales-agent/src/connectors/news.py @@ -0,0 +1,35 @@ +"""News/Web harvester. + +Uses the real Exa.ai connector when enabled (and within its request ceiling); +otherwise falls back to placeholder fixtures so the loop still runs offline. +""" +from __future__ import annotations + +from ..types import RawSignal +from . import account_fixture +from . import exa + + +def _from_fixtures(account: dict) -> list[RawSignal]: + out: list[RawSignal] = [] + for item in account_fixture(account["accountId"]).get("news", []): + out.append( + RawSignal( + account_id=account["accountId"], + source="news", + signal_type="press_release", + content=f"{item.get('title', '')}. {item['content']}", + published_at=item["publishedAt"], + url=item["url"], + extra={"title": item.get("title", ""), "via": "fixture"}, + ) + ) + return out + + +def harvest(account: dict) -> list[RawSignal]: + # Real source first; None means disabled/capped/errored -> use fixtures. + via_exa = exa.harvest_news(account) + if via_exa is not None: + return via_exa + return _from_fixtures(account) diff --git a/sales-agent/src/delivery/__init__.py b/sales-agent/src/delivery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sales-agent/src/delivery/digest.py b/sales-agent/src/delivery/digest.py new file mode 100644 index 0000000..0bb7423 --- /dev/null +++ b/sales-agent/src/delivery/digest.py @@ -0,0 +1,60 @@ +"""Daily Intelligence Digest (email/Slack stand-in -> console + markdown). + +Consolidated, per-rep, ordered by composite score, standard priority and above. +""" +from __future__ import annotations + +from collections import defaultdict + +from .. import config +from ..memory import store +from ..types import now_utc, parse_dt + + +def build_digest(rep_id: str | None = None) -> str: + briefs = [b for b in store.load_briefs() if b.get("priority") in ("priority", "standard")] + # Only active (not expired, still pending) opportunities. + active = [] + for b in briefs: + if b.get("status") not in (None, "pending"): + continue + try: + if parse_dt(b["expires_at"]) < now_utc(): + continue + except Exception: + pass + if rep_id and b["assigned_rep"]["id"] != rep_id: + continue + active.append(b) + + by_rep: dict[str, list[dict]] = defaultdict(list) + for b in active: + by_rep[b["assigned_rep"]["id"]].append(b) + + out = ["═══════════════════════════════════════════════", + f"📬 DAILY INTELLIGENCE DIGEST — {now_utc().strftime('%Y-%m-%d')}", + "═══════════════════════════════════════════════", ""] + if not active: + out.append("No active priority/standard opportunities in the queue.") + return "\n".join(out) + + for rid, items in by_rep.items(): + items.sort(key=lambda x: x["composite_score"], reverse=True) + rep_name = items[0]["assigned_rep"]["name"] + out.append(f"▶ {rep_name} ({rid}) — {len(items)} opportunit" + f"{'y' if len(items)==1 else 'ies'}") + for b in items: + flag = "🚨" if b["priority"] == "priority" else "📊" + out.append(f" {flag} [{b['composite_score']}] {b['account_name']} — {b['capability_name']}") + out.append(f" {b['headline']}") + out.append(f" brief: {b['id']} | confidence {round(b['confidence_score']*100)}%") + out.append("") + return "\n".join(out) + + +def write_digest(rep_id: str | None = None) -> str: + config.ensure_dirs() + text = build_digest(rep_id) + path = config.OUTPUT_DIR / "digest.md" + path.write_text(text) + return text diff --git a/sales-agent/src/delivery/render.py b/sales-agent/src/delivery/render.py new file mode 100644 index 0000000..9f2568c --- /dev/null +++ b/sales-agent/src/delivery/render.py @@ -0,0 +1,93 @@ +"""Phase 7: Delivery rendering. + +v1 delivery channels: + - console (priority briefs printed inline, Slack-style) + - markdown file per brief under output/briefs/ (stand-in for Slack/CRM/UI) +Production: Slack Bolt message with action buttons + CRM activity write-back. +""" +from __future__ import annotations + +from .. import config + +_PRIORITY_EMOJI = {"priority": "🚨", "standard": "📊", "low": "🗒️"} + + +def render_brief_md(brief: dict) -> str: + pct = round(brief["confidence_score"] * 100) + lines = [ + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + "🎯 OPPORTUNITY BRIEF", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + f"Account: {brief['account_name']}", + f"Capability: {brief['capability_name']}", + f"Assigned: {brief['assigned_rep']['name']} ({brief['assigned_rep']['email']})", + f"Brief ID: {brief['id']}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + "", + f"HEADLINE", + f" {brief['headline']}", + "", + f"CONFIDENCE: {pct}% URGENCY: {brief['urgency_level'].upper()} " + f"SCORE: {brief['composite_score']} PRIORITY: {brief['priority'].upper()}", + "", + "EXECUTIVE SUMMARY", + f" {brief['executive_summary']}", + "", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + "CONTRIBUTING SIGNALS", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + ] + for i, s in enumerate(brief["signals"], 1): + author = "" + if s.get("author"): + author = f" — {s['author'].get('name')} ({s['author'].get('title')})" + lines.append(f"[{i}] {s['source'].upper()}/{s['signal_type']}{author} " + f"({s['published_at'][:10]}) [{s['strength']}]") + lines.append(f' "{s["content"][:300]}"') + lines.append(f" {s['url']}") + lines.append("") + lines += [ + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + "TALKING POINTS", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + ] + for i, tp in enumerate(brief["talking_points"], 1): + lines.append(f"{i}. {tp}") + lines += [ + "", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + "SUGGESTED OUTREACH", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + "Target:", + ] + for c in brief["target_contacts"]: + crm = "in CRM" if c.get("inCrm") else "not yet in CRM" + last = f", last contact {c['lastContactDays']}d ago" if c.get("lastContactDays") else "" + lines.append(f" {c.get('name')} — {c.get('title')} ({crm}{last})") + lines += [ + "", + "Opener:", + f" {brief['outreach_hook']}", + "", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + f"Act: python run.py feedback --brief {brief['id']} --action act_on", + f"Snooze: python run.py feedback --brief {brief['id']} --action snooze", + f"Reject: python run.py feedback --brief {brief['id']} --action reject", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + ] + return "\n".join(lines) + + +def deliver(brief: dict) -> None: + """Write the brief to a file; print priority briefs to console (Slack stand-in).""" + config.ensure_dirs() + path = config.BRIEFS_DIR / f"{brief['priority']}_{brief['account_id']}_{brief['id']}.md" + path.write_text(render_brief_md(brief)) + emoji = _PRIORITY_EMOJI.get(brief["priority"], "•") + if brief["priority"] == "priority": + print() + print(render_brief_md(brief)) + print() + else: + print(f" {emoji} [{brief['priority']}] {brief['account_name']} — " + f"{brief['capability_name']} (score {brief['composite_score']}) -> {path.name}") diff --git a/sales-agent/src/llm.py b/sales-agent/src/llm.py new file mode 100644 index 0000000..3352885 --- /dev/null +++ b/sales-agent/src/llm.py @@ -0,0 +1,105 @@ +"""LLM access layer (provider-agnostic). + +Dispatches to a configured provider — Gemini or Anthropic — via plain urllib +(no SDKs). When no key is configured it falls back to a deterministic mock +reasoner so the full loop runs offline. All paths return parsed JSON dicts. +""" +from __future__ import annotations + +import json +import re +import urllib.request +import urllib.error + +from . import config + + +class LLMError(RuntimeError): + pass + + +def _extract_json(text: str) -> dict: + """Pull the first JSON object out of a model response.""" + text = text.strip() + # Strip ```json fences if present. + fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) + if fence: + text = fence.group(1) + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1: + raise LLMError(f"No JSON object found in LLM output: {text[:200]}") + return json.loads(text[start : end + 1]) + + +def _post(url: str, payload: dict, headers: dict, provider: str) -> dict: + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"content-type": "application/json", **headers}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8")[:400] if hasattr(e, "read") else "" + raise LLMError(f"{provider} API error {e.code}: {detail}") + except urllib.error.URLError as e: + raise LLMError(f"Network error calling {provider} API: {e}") + + +def _call_gemini(system: str, user: str, max_tokens: int) -> dict: + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{config.GEMINI_MODEL}:generateContent" + ) + payload = { + "system_instruction": {"parts": [{"text": system}]}, + "contents": [{"role": "user", "parts": [{"text": user}]}], + "generationConfig": { + "maxOutputTokens": max_tokens, + "temperature": 0.2, + "responseMimeType": "application/json", + }, + } + body = _post(url, payload, {"x-goog-api-key": config.GEMINI_API_KEY}, "Gemini") + candidates = body.get("candidates", []) + if not candidates: + raise LLMError(f"Gemini returned no candidates: {json.dumps(body)[:300]}") + parts = candidates[0].get("content", {}).get("parts", []) + text = "".join(p.get("text", "") for p in parts) + return _extract_json(text) + + +def _call_anthropic(system: str, user: str, max_tokens: int) -> dict: + payload = { + "model": config.LLM_MODEL, + "max_tokens": max_tokens, + "system": system, + "messages": [{"role": "user", "content": user}], + } + headers = { + "x-api-key": config.ANTHROPIC_API_KEY, + "anthropic-version": "2023-06-01", + } + body = _post("https://api.anthropic.com/v1/messages", payload, headers, "Anthropic") + text = "".join(b.get("text", "") for b in body.get("content", [])) + return _extract_json(text) + + +def call_json(system: str, user: str, max_tokens: int = 1500) -> dict: + """Single-turn LLM call that returns parsed JSON.""" + if config.USE_MOCK_LLM: + raise _MockSignal() # callers catch this and run their mock path + if config.LLM_PROVIDER == "gemini": + return _call_gemini(system, user, max_tokens) + return _call_anthropic(system, user, max_tokens) + + +class _MockSignal(Exception): + """Internal sentinel: tells the caller to use its deterministic mock branch.""" + + +def is_mock() -> bool: + return config.USE_MOCK_LLM diff --git a/sales-agent/src/memory/__init__.py b/sales-agent/src/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sales-agent/src/memory/feedback.py b/sales-agent/src/memory/feedback.py new file mode 100644 index 0000000..fc5476c --- /dev/null +++ b/sales-agent/src/memory/feedback.py @@ -0,0 +1,121 @@ +"""Feedback ingestion + weight recalibration (Phase 6 of the design).""" +from __future__ import annotations + +import json +from pathlib import Path + +from .. import config +from ..types import now_utc, iso, fingerprint +from . import store + +VALID_ACTIONS = {"act_on", "snooze", "reject", "won", "lost"} +_STATUS_MAP = { + "act_on": "accepted", + "snooze": "snoozed", + "reject": "rejected", + "won": "won", + "lost": "lost", +} + + +def _feedback_path() -> Path: + return config.STATE_DIR / "feedback.json" + + +def _weights_path() -> Path: + return config.STATE_DIR / "scoring_weights.json" + + +def load_feedback() -> list[dict]: + p = _feedback_path() + return json.loads(p.read_text()) if p.exists() else [] + + +def record_feedback(brief_id: str, action: str, note: str = "") -> dict: + if action not in VALID_ACTIONS: + raise ValueError(f"action must be one of {sorted(VALID_ACTIONS)}") + brief = store.get_brief(brief_id) + if not brief: + raise ValueError(f"brief {brief_id} not found") + + entry = { + "id": fingerprint(brief_id, action, iso(now_utc())), + "briefId": brief_id, + "accountId": brief["account_id"], + "capabilityId": brief["capability_id"], + "repId": brief["assigned_rep"]["id"], + "action": action, + "feedbackText": note, + "signalTypes": [s.get("signal_type") for s in brief.get("signals", [])], + "accountTier": brief.get("metadata", {}).get("tier") + or brief.get("account_tier", "unknown"), + "createdAt": iso(now_utc()), + } + config.ensure_dirs() + all_fb = load_feedback() + all_fb.append(entry) + _feedback_path().write_text(json.dumps(all_fb, indent=2)) + + # Reflect the action on the brief + account memory. + brief["status"] = _STATUS_MAP[action] + store.save_brief(brief) + mem = store.load_memory(brief["account_id"]) + for opp in mem["surfacedOpportunities"]: + if opp["briefId"] == brief_id: + opp["status"] = brief["status"] + # A 'reject' adds a 14-day suppression for that capability on that account. + if action == "reject": + mem["suppressions"].append( + { + "capabilityId": brief["capability_id"], + "expiresAt": iso(now_utc().replace(microsecond=0)), + "reason": note or "rep marked not relevant", + } + ) + store.save_memory(mem) + return entry + + +def load_weights() -> dict: + p = _weights_path() + return json.loads(p.read_text()) if p.exists() else {} + + +def feedback_weight(capability_id: str, account_tier: str, signal_types: list[str]) -> float: + """Look up the learned rep-feedback weight for a (capability, tier, signal) combo. + + Returns 0.5 (neutral) when there isn't enough history. Range 0..1. + """ + weights = load_weights() + vals = [] + for st in signal_types: + key = f"{capability_id}|{account_tier}|{st}" + if key in weights: + vals.append(weights[key]["weight"]) + if not vals: + return 0.5 + return sum(vals) / len(vals) + + +def recalibrate() -> dict: + """Weekly job: acceptance rate by capability x tier x signal_type (min 3 samples).""" + fb = load_feedback() + buckets: dict[str, dict[str, int]] = {} + for e in fb: + for st in e.get("signalTypes", []) or ["unknown"]: + key = f"{e['capabilityId']}|{e['accountTier']}|{st}" + b = buckets.setdefault(key, {"positive": 0, "total": 0}) + b["total"] += 1 + if e["action"] in ("act_on", "won"): + b["positive"] += 1 + + weights = {} + for key, b in buckets.items(): + if b["total"] >= 3: + weights[key] = { + "weight": round(b["positive"] / b["total"], 3), + "samples": b["total"], + } + config.ensure_dirs() + _weights_path().write_text(json.dumps(weights, indent=2)) + return weights diff --git a/sales-agent/src/memory/store.py b/sales-agent/src/memory/store.py new file mode 100644 index 0000000..c7143e6 --- /dev/null +++ b/sales-agent/src/memory/store.py @@ -0,0 +1,121 @@ +"""File-based account memory + brief store. + +v1 stand-in for Redis (hash dedup) + pgvector (semantic dedup) + Postgres +(opportunity history). Everything persists as JSON under output/state/. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from .. import config +from ..types import now_utc, iso, parse_dt + + +def _account_path(account_id: str) -> Path: + return config.STATE_DIR / f"memory_{account_id}.json" + + +def load_memory(account_id: str) -> dict: + config.ensure_dirs() + p = _account_path(account_id) + if p.exists(): + return json.loads(p.read_text()) + return { + "accountId": account_id, + "signalHashes": [], # [{hash, discoveredAt, signalType}] + "surfacedOpportunities": [], # [{briefId, capabilityId, surfacedAt, status}] + "suppressions": [], # [{capabilityId?, signalType?, expiresAt?}] + "accountNarrative": "", + "lastUpdated": None, + } + + +def save_memory(mem: dict) -> None: + config.ensure_dirs() + mem["lastUpdated"] = iso(now_utc()) + _account_path(mem["accountId"]).write_text(json.dumps(mem, indent=2)) + + +def seen_hashes(mem: dict) -> set[str]: + """Return signal hashes seen within the dedup window.""" + cutoff = now_utc() + out = set() + for entry in mem.get("signalHashes", []): + try: + age_days = (cutoff - parse_dt(entry["discoveredAt"])).days + except Exception: + age_days = 0 + if age_days <= config.SEMANTIC_DEDUP_DAYS: + out.add(entry["hash"]) + return out + + +def record_signals(mem: dict, signals: list) -> None: + for s in signals: + mem["signalHashes"].append( + {"hash": s.id, "discoveredAt": s.discovered_at, "signalType": s.signal_type} + ) + + +def is_suppressed(mem: dict, capability_id: str, signal_type: str) -> bool: + now = now_utc() + for rule in mem.get("suppressions", []): + exp = rule.get("expiresAt") + if exp and parse_dt(exp) < now: + continue + if rule.get("capabilityId") and rule["capabilityId"] != capability_id: + continue + if rule.get("signalType") and rule["signalType"] != signal_type: + continue + return True + return False + + +def record_brief(mem: dict, brief: dict) -> None: + mem["surfacedOpportunities"].append( + { + "briefId": brief["id"], + "capabilityId": brief["capability_id"], + "surfacedAt": brief["created_at"], + "status": brief.get("status", "pending"), + } + ) + + +def recent_capability_surfaced(mem: dict, capability_id: str, days: int) -> bool: + now = now_utc() + for opp in mem.get("surfacedOpportunities", []): + if opp["capabilityId"] != capability_id: + continue + if (now - parse_dt(opp["surfacedAt"])).days <= days: + return True + return False + + +# --- Brief persistence (the "primary DB" of opportunity history) --- + +def briefs_index_path() -> Path: + return config.STATE_DIR / "briefs_index.json" + + +def save_brief(brief: dict) -> None: + config.ensure_dirs() + idx = load_briefs() + idx = [b for b in idx if b["id"] != brief["id"]] + idx.append(brief) + briefs_index_path().write_text(json.dumps(idx, indent=2)) + + +def load_briefs() -> list[dict]: + p = briefs_index_path() + if p.exists(): + return json.loads(p.read_text()) + return [] + + +def get_brief(brief_id: str) -> dict | None: + for b in load_briefs(): + if b["id"] == brief_id: + return b + return None diff --git a/sales-agent/src/pipeline.py b/sales-agent/src/pipeline.py new file mode 100644 index 0000000..9a6cfc3 --- /dev/null +++ b/sales-agent/src/pipeline.py @@ -0,0 +1,95 @@ +"""The account cycle — the closed loop, run per account (design section 7.3).""" +from __future__ import annotations + +from . import config +from .agents import harvester, normalizer, relevance, synthesizer, scorer +from .delivery import render +from .connectors import crm, exa +from .memory import store + + +def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]: + aid = account["accountId"] + if verbose: + print(f"\n=== {account['accountName']} ({account['accountTier']}) [{aid}] ===") + mem = store.load_memory(aid) + + # 1. Harvest + raw = harvester.run_harvesters(account) + if verbose: + print(f" harvested {len(raw)} raw signal(s)") + + # 2. Normalize + dedup + signals = normalizer.normalize_and_dedup(raw, account, mem) + if verbose: + print(f" {len(signals)} new signal(s) after dedup") + if not signals: + store.save_memory(mem) + return [] + + # 3. Relevance filter (LLM reasoning vs. capability library) + relevant = relevance.filter_for_relevance(signals, account) + if verbose: + print(f" {len(relevant)} relevant signal(s)") + # Record all processed signals so we don't re-evaluate them next run. + store.record_signals(mem, signals) + if not relevant: + store.save_memory(mem) + return [] + + # 4. Synthesize into opportunity briefs + briefs = synthesizer.synthesize(relevant, account) + + # 5. Score + prioritize + briefs = scorer.score_and_prioritize(briefs, account) + + # Apply suppression / re-surface guard, then deliver. + delivered: list[dict] = [] + for b in briefs: + if store.is_suppressed(mem, b.capability_id, ""): + if verbose: + print(f" suppressed: {b.capability_name}") + continue + if store.recent_capability_surfaced(mem, b.capability_id, config.SAME_TYPE_SUPPRESS_DAYS): + if verbose: + print(f" skipped (surfaced < {config.SAME_TYPE_SUPPRESS_DAYS}d ago): {b.capability_name}") + continue + if b.priority == "low": + if verbose: + print(f" logged (below delivery threshold): {b.capability_name} " + f"(score {b.composite_score})") + store.save_brief(b.to_dict()) + continue + + bd = b.to_dict() + store.save_brief(bd) + store.record_brief(mem, bd) + render.deliver(bd) # 6. Deliver + crm.log_activity(aid, bd) # + CRM write-back stub + delivered.append(bd) + + # 7. Update memory + store.save_memory(mem) + return delivered + + +def run_all(tier: str | None = None, account_id: str | None = None) -> list[dict]: + config.ensure_dirs() + accounts = crm.load_accounts(tier=tier, account_id=account_id) + if not accounts: + print("No matching accounts.") + return [] + print(f"Running prospecting loop over {len(accounts)} account(s) — LLM: {config.llm_label()}") + if config.EXA_ENABLED: + st = exa.usage_status() + scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web" + print(f"News source: Exa.ai ({scope}) — monthly budget {st['used_this_month']}/{st['monthly_cap']} " + f"used, {st['monthly_remaining']} left (per-run cap {st['per_run_cap']})") + else: + print("News source: fixtures (Exa disabled — set EXA_API_KEY to enable)") + all_delivered: list[dict] = [] + for acct in accounts: + all_delivered.extend(run_account_cycle(acct)) + print(f"\nDone. Delivered {len(all_delivered)} brief(s). " + f"Files in {config.BRIEFS_DIR}") + return all_delivered diff --git a/sales-agent/src/types.py b/sales-agent/src/types.py new file mode 100644 index 0000000..55ce4d3 --- /dev/null +++ b/sales-agent/src/types.py @@ -0,0 +1,109 @@ +"""Core data structures for the prospecting loop (mirrors the design's interfaces).""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from typing import Any, Optional + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def parse_dt(value: str) -> datetime: + if value.endswith("Z"): + value = value[:-1] + "+00:00" + return datetime.fromisoformat(value) + + +def fingerprint(*parts: str) -> str: + h = hashlib.sha256("||".join(parts).encode("utf-8")).hexdigest() + return h[:16] + + +@dataclass +class RawSignal: + account_id: str + source: str # linkedin | news | jobs | sec_filing + signal_type: str + content: str + published_at: str + url: str + author: Optional[dict] = None + engagement: Optional[dict] = None + extra: dict = field(default_factory=dict) + + +@dataclass +class NormalizedSignal: + id: str + account_id: str + source: str + signal_type: str + content: str + published_at: str + discovered_at: str + url: str + author: Optional[dict] = None + metadata: dict = field(default_factory=dict) + + @staticmethod + def from_raw(raw: RawSignal) -> "NormalizedSignal": + sid = fingerprint(raw.source, raw.url, raw.content[:120]) + return NormalizedSignal( + id=sid, + account_id=raw.account_id, + source=raw.source, + signal_type=raw.signal_type, + content=raw.content.strip(), + published_at=raw.published_at, + discovered_at=iso(now_utc()), + url=raw.url, + author=raw.author, + metadata={"engagement": raw.engagement or {}, **raw.extra}, + ) + + +@dataclass +class RelevanceResult: + """Output of the LLM relevance filter for a single signal.""" + signal_id: str + relevant: bool + capability_ids: list[str] + rationale: str + signal_strength: str # strong | moderate | weak + is_expansion: bool + ideal_contact: dict # {title, department} + suggested_angle: str + + +@dataclass +class OpportunityBrief: + id: str + account_id: str + account_name: str + capability_id: str + capability_name: str + headline: str + executive_summary: str + signal_ids: list[str] + signals: list[dict] + talking_points: list[str] + outreach_hook: str + target_contacts: list[dict] + urgency_level: str # high | medium | low + confidence_score: float # 0..1 from the LLM + composite_score: float # weighted score from the scorer + priority: str # priority | standard | low + assigned_rep: dict + created_at: str + expires_at: str + status: str = "pending" # pending | accepted | rejected | snoozed | won | lost + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/sales-prospecting-agent-loop.md b/sales-prospecting-agent-loop.md new file mode 100644 index 0000000..621b19b --- /dev/null +++ b/sales-prospecting-agent-loop.md @@ -0,0 +1,884 @@ +# Enterprise Sales Prospecting Agent Loop +## Account Intelligence System for Cross-Sell Opportunity Detection + +--- + +## 1. Overview & Vision + +The goal is a continuously running, closed-loop intelligence system that monitors a defined list of target accounts and surfaces high-confidence cross-sell opportunities to your sales team before they have to go looking. Rather than relying on reps to manually track news, LinkedIn, and account signals, the system does this automatically — ingesting raw signals, running them through an AI reasoning layer that understands your product capabilities, scoring them, and routing actionable briefs to the right person at the right time. + +The system operates on three core principles: + +- **Signal-first:** Every output is grounded in a real, verifiable business signal (a LinkedIn post, a job posting, a press release) — never speculation. +- **Capability-mapped:** Every signal is evaluated against a structured library of your product capabilities, so the output is always tied to something you can actually sell. +- **Closed-loop:** Sales rep feedback flows back into the system, continuously improving signal relevance, scoring weights, and messaging quality. + +--- + +## 2. System Architecture + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ AGENT ORCHESTRATOR │ +│ (Scheduler + Job Queue + State) │ +└────────────────────────┬───────────────────────────────────────────────┘ + │ dispatches per-account jobs + ┌──────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌───────────────┐ ┌──────────────┐ ┌──────────────────┐ + │ SIGNAL │ │ SIGNAL │ │ SIGNAL │ + │ HARVESTER │ │ HARVESTER │ │ HARVESTER │ + │ (LinkedIn) │ │ (News/Web) │ │ (Job Postings) │ + └───────┬───────┘ └──────┬───────┘ └──────────┬───────┘ + └─────────────────┼──────────────────────┘ + │ raw signals + ▼ + ┌─────────────────────┐ + │ SIGNAL NORMALIZER │ + │ (dedup + enrich) │ + └──────────┬──────────┘ + │ structured signal objects + ▼ + ┌─────────────────────┐ + │ RELEVANCE FILTER │◄─── Capability Library + │ (LLM reasoning) │ + └──────────┬──────────┘ + │ relevant signals only + ▼ + ┌─────────────────────┐ + │ OPPORTUNITY │◄─── Account Context (CRM) + │ SYNTHESIZER │◄─── Historical Feedback + │ (LLM + scoring) │ + └──────────┬──────────┘ + │ scored opportunity briefs + ▼ + ┌─────────────────────┐ + │ DEDUP & MEMORY │ + │ (Redis + pgvector) │ + └──────────┬──────────┘ + │ new, non-duplicate opportunities + ▼ + ┌─────────────────────┐ + │ DELIVERY LAYER │ + │ (Slack / CRM / UI) │ + └──────────┬──────────┘ + │ feedback + ▼ + ┌─────────────────────┐ + │ FEEDBACK ENGINE │ + │ (scoring updates) │ + └─────────────────────┘ +``` + +--- + +## 3. Data Sources & Signal Harvesting + +### 3.1 LinkedIn Intelligence + +LinkedIn is the highest-signal source for executive intent, strategic direction, and organizational change. + +**What to harvest:** +- Posts from C-suite, VP-level, and Director-level contacts at target accounts +- Company page updates and announcements +- Job postings (particularly in functions that correlate with buying decisions) +- Headcount change patterns (rapid hiring vs. contraction in specific departments) +- Executive profile changes (new roles, new certifications, new responsibilities) + +**How to harvest:** +- **Proxycurl API** — enriches LinkedIn profiles and company pages programmatically, GDPR-compliant +- **Apify LinkedIn scrapers** — for post content and activity feeds at scale +- **LinkedIn Sales Navigator API** (if licensed) — provides official access to account activity feeds and alerts +- **RocketReach / Apollo.io API** — supplemental contact and org chart data + +**Signal types extracted:** +```json +{ + "type": "linkedin_post", + "account_id": "acct_123", + "author": { "name": "Jane Smith", "title": "CTO", "seniority": "C-suite" }, + "content": "We're accelerating our digital transformation journey...", + "engagement": { "likes": 142, "comments": 38 }, + "published_at": "2026-06-18T14:22:00Z", + "raw_url": "https://linkedin.com/posts/..." +} +``` + +### 3.2 News & Web Intelligence + +**What to harvest:** +- Press releases (funding rounds, acquisitions, partnerships, new product launches) +- Earnings call transcripts (public companies) — rich in strategic intent signals +- Industry publication coverage +- Executive interviews and keynote summaries +- Regulatory filings (10-K, 10-Q for public companies via SEC EDGAR) + +**How to harvest:** +- **Exa.ai** — semantic web search with date-filtering, excellent for "find me news about Acme Corp in the last 30 days" +- **NewsAPI / GDELT** — broad news aggregation with keyword and entity filtering +- **SEC EDGAR API** — free, official access to all public company filings +- **Perplexity API** — for summarizing and reasoning over web content +- **RSS feeds** — many company blogs and investor relations pages expose RSS + +### 3.3 Job Posting Intelligence + +Job postings are one of the most underutilized signals in B2B sales. A company posting for a "VP of Financial Operations" or "Director of Revenue Operations" is telling you they're scaling a function — and likely buying new tools for it. + +**What to harvest:** +- Job titles in departments relevant to your product capabilities +- Technology stack mentions in job descriptions ("must have experience with Salesforce, looking to migrate from legacy ERP...") +- Volume of hiring in specific functions (trend data) +- Keywords like "build vs. buy," "digital transformation," "consolidate," "modernize" + +**How to harvest:** +- **JSearch API (via RapidAPI)** — aggregates LinkedIn Jobs, Indeed, Glassdoor +- **Adzuna API** — free tier available, covers major job boards +- **Coresignal** — provides bulk job posting data with company mapping + +### 3.4 CRM Context (Internal) + +Every signal is enriched with internal CRM context to prevent surfacing noise: + +- Current products the account has purchased +- Open opportunities and their stages +- Last activity date with the account +- Account tier / strategic priority +- Assigned rep and territory +- Known stakeholders and their roles +- Prior AI-surfaced signals and their outcomes + +**Integration:** Salesforce REST API, HubSpot API, or direct DB query if self-hosted. + +### 3.5 Product Usage Signals (If Available) + +If your enterprise software has usage telemetry: + +- Features actively used vs. features licensed but dormant +- Usage trends (increasing engagement in Module A could signal readiness for Module B) +- Login frequency and user adoption by department +- Support ticket patterns (frustration with a gap your cross-sell product fills) + +--- + +## 4. The Agent Loop — Step-by-Step + +### Phase 1: Signal Harvesting (Per Account, Per Run) + +```typescript +interface AccountJob { + accountId: string; + accountName: string; + domain: string; + targetContacts: Contact[]; // from CRM + currentProducts: string[]; // what they already have + accountTier: 'strategic' | 'enterprise' | 'growth'; + lastRunAt: Date; +} + +async function runAccountHarvestCycle(job: AccountJob): Promise { + const signals = await Promise.allSettled([ + harvestLinkedInSignals(job), + harvestNewsSignals(job), + harvestJobPostingSignals(job), + harvestFilingsSignals(job), + ]); + + return signals + .filter(r => r.status === 'fulfilled') + .flatMap(r => (r as PromiseFulfilledResult).value); +} +``` + +The harvester runs on a per-account cadence: +- **Strategic accounts:** Every 24 hours +- **Enterprise accounts:** Every 48–72 hours +- **Growth accounts:** Weekly + +### Phase 2: Signal Normalization & Deduplication + +All signals from different sources are normalized into a common schema, then deduplicated against the memory store. + +```typescript +interface NormalizedSignal { + id: string; // hash of source + content fingerprint + accountId: string; + source: 'linkedin' | 'news' | 'jobs' | 'sec_filing' | 'usage'; + signalType: string; // 'executive_post' | 'hiring_surge' | 'press_release' | etc. + content: string; // normalized text content + metadata: Record; + publishedAt: Date; + discoveredAt: Date; + author?: { name: string; title: string; seniority: string }; + embedding?: number[]; // vector embedding for semantic dedup +} +``` + +**Deduplication strategy:** +1. **Hash-based:** Exact match on `source + url` — catches literal duplicates +2. **Semantic dedup:** Cosine similarity on embeddings against signals seen in the last 90 days — catches rephrased versions of the same story +3. **Window-based suppression:** If a signal of type `executive_post` for account X with similar themes was surfaced < 14 days ago, suppress unless engagement is 3x higher + +### Phase 3: Relevance Filtering (LLM Reasoning Layer) + +This is where raw signals are evaluated against your **Capability Library** — a structured description of every product/module you sell, what business problems it solves, and what signals suggest a buying opportunity. + +```typescript +interface Capability { + id: string; + name: string; + description: string; // plain language + businessProblems: string[]; // problems this solves + buyingSignals: string[]; // what to look for in the market + antiSignals: string[]; // signals that suggest they already have this + targetBuyer: { titles: string[]; departments: string[] }; +} +``` + +**Example Capability entry:** +```typescript +{ + id: "cap_financial_close", + name: "Automated Financial Close Module", + description: "Automates the month-end and quarter-end close process, reducing cycle time and manual reconciliation errors.", + businessProblems: [ + "Manual financial close taking 10+ days", + "Audit findings related to reconciliation errors", + "Controller or CFO unable to scale without adding headcount", + "Recent ERP migration creating reporting gaps" + ], + buyingSignals: [ + "Hiring for financial reporting or accounting manager roles", + "CFO/Controller posts about process improvement or modernization", + "Press release about acquisition (new entity to consolidate)", + "Mention of 'close process' or 'reconciliation' challenges in earnings calls", + "Job descriptions mentioning 'reduce close cycle time'" + ], + antiSignals: [ + "Already purchased this module per CRM", + "Recent competitor win logged in CRM" + ], + targetBuyer: { + titles: ["CFO", "Controller", "VP Finance", "Head of Accounting"], + departments: ["Finance", "Accounting"] + } +} +``` + +The LLM relevance filter receives: +- The normalized signal +- The account's current product footprint (to apply anti-signals) +- The full Capability Library + +**System prompt pattern:** +``` +You are an account intelligence analyst for an enterprise software company. + +Your job: evaluate whether a business signal indicates a genuine cross-sell opportunity for one of our product capabilities. + +ACCOUNT CONTEXT: +- Company: {{accountName}} +- Current Products: {{currentProducts}} +- Account Tier: {{accountTier}} + +SIGNAL: +{{signal.content}} +Source: {{signal.source}} | Type: {{signal.signalType}} | Date: {{signal.publishedAt}} + +CAPABILITY LIBRARY: +{{capabilityLibrary}} + +INSTRUCTIONS: +1. Determine if this signal indicates a business need that one or more of our capabilities directly addresses. +2. If yes, identify the specific capability ID(s) and explain WHY this signal is relevant. +3. If the account already has that capability, mark it as an expansion signal instead. +4. Assign a signal strength: strong | moderate | weak +5. Identify the ideal contact to approach (by title/function) based on the signal. +6. If no relevance exists, return null. + +Return JSON only. No preamble. +``` + +**Output:** +```json +{ + "relevant": true, + "capabilityIds": ["cap_financial_close"], + "rationale": "The CFO's post explicitly mentions 'closing the books faster' as a Q3 priority following their recent acquisition of MidWest Holdings. The acquisition creates a new consolidation need our module directly addresses.", + "signalStrength": "strong", + "signalType": "executive_intent + strategic_event", + "idealContact": { "title": "CFO", "department": "Finance" }, + "suggestedAngle": "Post-acquisition consolidation complexity as the entry point conversation" +} +``` + +### Phase 4: Opportunity Synthesis + +Relevant signals are grouped per account and synthesized into a single **Opportunity Brief** using a second LLM pass. This aggregates multiple weak signals into a coherent narrative, or elevates a single strong signal into a full sales brief. + +```typescript +interface OpportunityBrief { + id: string; + accountId: string; + accountName: string; + capabilityId: string; + capabilityName: string; + + headline: string; // 1-sentence summary + executiveSummary: string; // 2-3 sentence brief for the rep + signals: EnrichedSignal[]; // all contributing signals + + suggestedTalkingPoints: string[]; // 3-5 specific points grounded in signals + suggestedOutreachHook: string; // opening line for email or LinkedIn DM + + targetContacts: Contact[]; // from CRM + signal author enrichment + urgencyLevel: 'high' | 'medium' | 'low'; + confidenceScore: number; // 0.0–1.0 + + createdAt: Date; + expiresAt: Date; // opportunities decay if not acted on + assignedRep: string; +} +``` + +**Second-pass synthesis prompt** (abbreviated): +``` +You are synthesizing multiple account intelligence signals into a single, actionable sales opportunity brief. + +SIGNALS (already validated as relevant): +{{signals}} + +ACCOUNT CONTEXT: +{{accountContext}} + +TARGET CAPABILITY: +{{capability}} + +Create a brief that: +- Opens with the single most compelling signal +- Builds a coherent narrative connecting all signals +- Proposes 3-5 specific talking points tied directly to signals +- Suggests an opening hook for outreach (LinkedIn DM or email opener) +- Does not over-promise — stay grounded in what the signals actually say + +Return JSON matching the OpportunityBrief schema. +``` + +### Phase 5: Scoring & Prioritization + +Each brief receives a composite score used to determine delivery urgency and rep queue position. + +``` +compositeScore = + (signalStrength × 0.35) + + (accountTier × 0.25) + + (buyerSeniority × 0.20) + + (timingUrgency × 0.15) + + (repFeedbackWeight × 0.05) +``` + +**Scoring dimensions:** + +| Dimension | High | Medium | Low | +|---|---|---|---| +| Signal Strength | Multiple strong signals | 1 strong or 2 moderate | Weak signals only | +| Account Tier | Strategic | Enterprise | Growth | +| Buyer Seniority | C-suite or EVP | VP/Director | Manager | +| Timing Urgency | <7 days since signal | 7–21 days | 21+ days | +| Rep Feedback Weight | Rep has marked similar opps as useful | Neutral history | Rep has marked similar as noise | + +Briefs scoring above **0.72** are routed as **Priority** (immediate Slack notification). Briefs between **0.45–0.72** are queued as **Standard** (daily digest). Below **0.45** are logged but not delivered unless the rep opts into low-confidence signals. + +### Phase 6: Memory & State Management + +```typescript +// What gets persisted per account +interface AccountMemory { + accountId: string; + + // Signal fingerprints seen in last 90 days (for dedup) + signalHashes: string[]; + signalEmbeddings: { hash: string; embedding: number[] }[]; + + // Opportunities surfaced (to avoid re-surfacing same opp) + surfacedOpportunities: { + briefId: string; + capabilityId: string; + surfacedAt: Date; + repAction: 'accepted' | 'rejected' | 'snoozed' | 'pending'; + repFeedback?: string; + }[]; + + // Suppression rules (rep-defined) + suppressions: { + capabilityId?: string; // "don't show me fin-close opps for this account" + signalType?: string; + expiresAt?: Date; + }[]; + + // Rep-observed context (from CRM notes, enriched by AI parsing) + accountNarrative: string; // running AI-maintained summary of account status + + lastUpdated: Date; +} +``` + +**Memory store implementation:** +- **Redis** — signal hash dedup (fast O(1) lookups, TTL-based expiry) +- **pgvector (PostgreSQL)** — embedding similarity search for semantic dedup and "find similar opportunities" +- **Primary DB** — full opportunity history, account memory, feedback records + +### Phase 7: Delivery Layer + +#### Slack Integration +Real-time delivery for high-priority opportunities: + +``` +🚨 *New Cross-Sell Signal — Acme Corp* | Priority: HIGH + +*Opportunity:* Automated Financial Close Module +*Confidence:* 87% | *Urgency:* High + +*Why Now:* +CFO Jane Smith posted yesterday about their post-acquisition integration +challenges, specifically calling out "reconciliation across three new +entities" as a Q3 blocker. They acquired MidWest Holdings 6 weeks ago. + +*Supporting Signals:* +• LinkedIn post by CFO (Jun 18) — 142 likes, 38 comments +• 3 open Finance Manager roles (posted Jun 10–14) referencing + "multi-entity consolidation" +• Earnings call transcript (May 15) — CFO mentioned "streamlining + financial operations" as top priority + +*Talking Points:* +1. Multi-entity consolidation is a known friction point post-acquisition +2. Our module reduces average close cycle by 60% in consolidation scenarios +3. Other [industry] clients scaled from 2 → 8 entities without adding headcount + +*Suggested Opener:* +"Jane — saw your post about the MidWest integration. We've helped three +other [industry] CFOs close the multi-entity gap without adding staff. +Worth 20 minutes?" + +*Best Contact:* Jane Smith (CFO) | Mike Torres (Controller — already in CRM) + +[✅ Act on This] [⏰ Snooze 2 Weeks] [❌ Not Relevant] +``` + +#### CRM Auto-Logging +Every surfaced brief is auto-logged as an activity against the account record, with: +- Signal summary +- Full opportunity brief +- Links to source signals +- Suggested next step task created and assigned to rep + +#### Daily Intelligence Digest (Email) +A consolidated morning email for each rep covering all active accounts, ordered by composite score, showing only standard-priority and above. Each entry is a 3-line summary with a link to the full brief in the web UI. + +#### Web Dashboard (Optional) +A purpose-built UI showing: +- Active opportunity queue per rep / per account +- Signal feed with filtering by capability, signal type, account tier +- Account 360 view showing all signals + opportunity history +- Capability coverage heatmap (which accounts have white space for which products) +- System health (run logs, signal volume, delivery rates) + +--- + +## 5. The Capability Mapping Engine + +The capability library is the brain of the system. It needs to be maintained by a product or sales enablement team and treated as a living document. + +### 5.1 Capability Library Structure + +```typescript +interface CapabilityLibrary { + version: string; + lastUpdated: Date; + capabilities: Capability[]; + + // Cross-capability rules + bundleSignals: { + capabilityIds: string[]; + description: string; // "When we see X + Y, pitch Z bundle" + }[]; + + // Account-level exclusion rules + globalAntiSignals: string[]; // "Competitor X win" → suppress all opps +} +``` + +### 5.2 Capability Embedding Index + +Every `buyingSignal` string in the library is embedded and stored in a vector index. When a new signal comes in, a fast ANN (approximate nearest neighbor) search can pre-filter which capabilities are candidates before sending to the LLM. This reduces LLM calls by ~60% and dramatically improves latency on high-volume runs. + +```typescript +// Pre-filter step before LLM evaluation +async function preFilterCapabilities( + signal: NormalizedSignal, + library: CapabilityLibrary +): Promise { + const signalEmbedding = await embed(signal.content); + + const similar = await vectorDB.query({ + vector: signalEmbedding, + topK: 5, + threshold: 0.75, + index: 'capability_buying_signals' + }); + + // Return only capabilities that have at least one similar buying signal + const candidateCapabilityIds = [...new Set(similar.map(r => r.capabilityId))]; + return library.capabilities.filter(c => candidateCapabilityIds.includes(c.id)); +} +``` + +--- + +## 6. Feedback Loop & Continuous Improvement + +The closed-loop aspect is what separates this from a simple alert system. Every rep action is a training signal. + +### 6.1 Feedback Actions + +- **✅ Act on This** → High-quality signal; boosts weight for this signal type + capability combination for this account tier +- **⏰ Snooze 2 Weeks** → Timing is off; opportunity is real but surfaced too early +- **❌ Not Relevant** → Low-quality signal; penalizes weight; rep can optionally specify why +- **💬 Custom Feedback** → Free-text comment, parsed by LLM to extract structured feedback +- **🏆 Won Deal** → Maximum positive signal; retrospectively boosts all signals that contributed +- **❌ Lost Deal** → Retrospective analysis — what signals did we have, what did we miss? + +### 6.2 Feedback Storage Schema + +```sql +CREATE TABLE opportunity_feedback ( + id UUID PRIMARY KEY, + brief_id UUID NOT NULL, + account_id TEXT NOT NULL, + capability_id TEXT NOT NULL, + rep_id TEXT NOT NULL, + action VARCHAR(20) NOT NULL, -- act_on | snooze | reject | won | lost + feedback_text TEXT, + feedback_parsed JSONB, -- structured extraction of free text + signal_types_present TEXT[], -- which signal types contributed + account_tier VARCHAR(20), + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 6.3 Weight Recalibration (Weekly Job) + +A weekly background job analyzes feedback patterns and adjusts scoring weights: + +```typescript +async function recalibrateScoring(): Promise { + // Calculate acceptance rate by signal_type × capability × account_tier + const acceptanceRates = await db.query(` + SELECT + capability_id, + account_tier, + unnest(signal_types_present) AS signal_type, + COUNT(*) FILTER (WHERE action IN ('act_on', 'won')) AS positive, + COUNT(*) AS total + FROM opportunity_feedback + WHERE created_at > NOW() - INTERVAL '90 days' + GROUP BY 1, 2, 3 + HAVING COUNT(*) >= 5 + `); + + // Update scoring weights in config + for (const row of acceptanceRates.rows) { + const rate = row.positive / row.total; + await updateScoringWeight({ + capabilityId: row.capability_id, + accountTier: row.account_tier, + signalType: row.signal_type, + weight: rate + }); + } +} +``` + +### 6.4 System-Level Prompt Refinement + +Monthly: run an LLM analysis over all rejected briefs to identify patterns in false positives, and use those to refine the relevance filter system prompt. This is a manual step reviewed by a product/sales ops owner before being deployed. + +--- + +## 7. Technical Implementation + +### 7.1 Technology Stack + +| Layer | Technology | Rationale | +|---|---|---| +| Language | TypeScript / Node.js | Matches existing stack; excellent async primitives | +| Orchestration | BullMQ (Redis-backed job queue) | Reliable job scheduling, retries, rate limiting | +| LLM | Anthropic Claude API (claude-sonnet-4-6) | Best-in-class reasoning for signal evaluation | +| Embeddings | `text-embedding-3-large` (OpenAI) or `voyage-3` (Anthropic) | High-quality semantic similarity | +| Vector DB | pgvector (PostgreSQL extension) | Avoids a separate vector DB if you're already on Postgres | +| Primary DB | PostgreSQL | Opportunity history, feedback, account memory | +| Cache / Dedup | Redis (Upstash or self-hosted) | Signal hash dedup, rate limiting, BullMQ | +| LinkedIn Data | Proxycurl + Apify | Compliant enrichment + post scraping | +| News/Web | Exa.ai | Semantic search over web, excellent B2B signal quality | +| Job Postings | JSearch API | Aggregates all major boards | +| CRM Integration | Salesforce / HubSpot REST API | Read account context, write back activity logs | +| Delivery | Slack API (Bolt SDK) + SendGrid | Notifications + digests | +| Hosting | Railway / Render / EC2 | Depends on scale; start with Railway for simplicity | + +### 7.2 Project Structure + +``` +/sales-agent +├── /src +│ ├── /agents +│ │ ├── harvester.ts # Signal collection per-source +│ │ ├── normalizer.ts # Signal standardization +│ │ ├── relevanceFilter.ts # LLM relevance evaluation +│ │ ├── synthesizer.ts # Opportunity brief generation +│ │ └── scorer.ts # Composite scoring +│ ├── /capabilities +│ │ ├── library.ts # Capability library loader +│ │ ├── embedder.ts # Build + query capability embeddings +│ │ └── capabilities.json # The actual capability definitions +│ ├── /connectors +│ │ ├── linkedin.ts # Proxycurl + Apify +│ │ ├── news.ts # Exa.ai + NewsAPI +│ │ ├── jobPostings.ts # JSearch +│ │ ├── crm.ts # Salesforce / HubSpot +│ │ └── secEdgar.ts # SEC filing fetcher +│ ├── /delivery +│ │ ├── slack.ts # Slack Bolt app + message formatting +│ │ ├── email.ts # Daily digest via SendGrid +│ │ └── crmLogger.ts # Write-back to CRM +│ ├── /memory +│ │ ├── dedup.ts # Hash + semantic dedup +│ │ ├── accountMemory.ts # Per-account state management +│ │ └── feedback.ts # Feedback ingestion + storage +│ ├── /jobs +│ │ ├── accountCycle.ts # Main per-account orchestration job +│ │ ├── digest.ts # Daily digest job +│ │ └── recalibrate.ts # Weekly scoring recalibration +│ ├── /prompts +│ │ ├── relevanceFilter.ts # Prompt templates +│ │ └── synthesizer.ts +│ └── /api +│ ├── webhooks.ts # Slack feedback webhooks +│ └── dashboard.ts # REST API for web UI (optional) +├── /migrations # DB migrations +├── docker-compose.yml # Local dev: Postgres + Redis +└── .env +``` + +### 7.3 Main Orchestration Job + +```typescript +// jobs/accountCycle.ts +import { Queue, Worker } from 'bullmq'; +import { redis } from '../lib/redis'; +import { runHarvesters } from '../agents/harvester'; +import { normalizeAndDedup } from '../agents/normalizer'; +import { filterForRelevance } from '../agents/relevanceFilter'; +import { synthesizeOpportunities } from '../agents/synthesizer'; +import { scoreAndPrioritize } from '../agents/scorer'; +import { deliverBriefs } from '../delivery'; +import { updateAccountMemory } from '../memory/accountMemory'; + +const accountQueue = new Queue('account-cycle', { connection: redis }); + +// Schedule all accounts +export async function scheduleAllAccounts(accounts: Account[]): Promise { + for (const account of accounts) { + const delay = getDelayForTier(account.tier); // 24h / 48h / 7d + await accountQueue.add( + 'run', + { accountId: account.id }, + { repeat: { every: delay }, jobId: `cycle-${account.id}` } + ); + } +} + +// Worker +const worker = new Worker('account-cycle', async (job) => { + const account = await loadAccountContext(job.data.accountId); + + // 1. Harvest + const rawSignals = await runHarvesters(account); + + // 2. Normalize + dedup + const signals = await normalizeAndDedup(rawSignals, account); + if (signals.length === 0) return; // Nothing new + + // 3. Relevance filter + const relevantSignals = await filterForRelevance(signals, account); + if (relevantSignals.length === 0) return; + + // 4. Synthesize into opportunity briefs + const briefs = await synthesizeOpportunities(relevantSignals, account); + + // 5. Score + prioritize + const scoredBriefs = await scoreAndPrioritize(briefs, account); + + // 6. Deliver + await deliverBriefs(scoredBriefs, account); + + // 7. Update memory + await updateAccountMemory(account, signals, scoredBriefs); + +}, { connection: redis, concurrency: 5 }); +``` + +### 7.4 Rate Limiting & API Cost Management + +LLM calls are the primary cost driver. Key mitigations: + +- **Capability pre-filter (vector search):** Reduces LLM calls by ~60% by only sending signals to the LLM when vector similarity suggests relevance +- **Signal batching:** Send up to 5 related signals in a single LLM call rather than one per call +- **Caching:** Cache LLM responses for identical signal content (rare but happens with news reposts) +- **Tier-based depth:** Strategic accounts get full LLM analysis; growth accounts get lighter-weight summarization +- **Estimated cost at scale:** ~$0.08–0.15 per account per daily run at full depth with Claude Sonnet + +--- + +## 8. Implementation Roadmap + +### Phase 1 — Foundation (Weeks 1–3) +- [ ] Set up PostgreSQL + Redis + pgvector +- [ ] Build Capability Library (start with top 5–10 capabilities) +- [ ] Implement Exa.ai news harvester (lowest integration friction) +- [ ] Build basic LLM relevance filter + synthesizer +- [ ] Slack delivery for a single test account +- [ ] Manual run (no scheduler yet) — validate output quality + +### Phase 2 — Core Loop (Weeks 4–6) +- [ ] Add BullMQ orchestrator + account scheduler +- [ ] Add LinkedIn harvester (Proxycurl for profile enrichment, Apify for posts) +- [ ] Add job posting harvester +- [ ] Build dedup layer (hash + semantic) +- [ ] Add CRM read integration (pull account context) +- [ ] Wire up feedback buttons in Slack + +### Phase 3 — Intelligence Layer (Weeks 7–9) +- [ ] Capability embedding index + pre-filter +- [ ] Composite scoring engine +- [ ] Account memory + suppression rules +- [ ] CRM write-back (log opportunities as activities) +- [ ] Daily digest email via SendGrid +- [ ] Feedback storage + rep preference tracking + +### Phase 4 — Closed Loop (Weeks 10–12) +- [ ] Weekly scoring recalibration job +- [ ] Feedback-driven prompt refinement workflow +- [ ] Account narrative maintenance (rolling AI-generated account summary) +- [ ] Web dashboard (opportunity queue UI, signal feed) +- [ ] System health monitoring + alerting +- [ ] Win/loss retrospective analysis + +--- + +## 9. Full Capability Delivery Summary + +| Capability | Description | +|---|---| +| **Signal-Grounded Intelligence** | Every opportunity is tied to a real, datable source — no speculation | +| **Multi-Source Signal Fusion** | Combines LinkedIn, news, job postings, filings, and CRM into a unified account view | +| **Continuous Monitoring** | Runs automatically on configurable cadences — reps don't have to go looking | +| **Capability-Mapped Relevance** | Signals are evaluated against your specific product capabilities, not generic firmographic data | +| **Anti-Signal Filtering** | Knows what the account already has and excludes opportunities they've closed or that don't apply | +| **Executive-Level Targeting** | Identifies the right contact based on who posted, what role is relevant, and who's in the CRM | +| **Urgency-Aware Scoring** | Prioritizes by signal recency, account tier, buyer seniority, and rep feedback history | +| **Semantic Deduplication** | Avoids re-surfacing the same story across different sources or with different wording | +| **CRM Bidirectional Sync** | Reads context from CRM; writes opportunities back as activities with tasks and owners | +| **Slack-Native Delivery** | One-click action buttons surface priority briefs in the rep's existing workflow | +| **Daily Intelligence Digest** | Morning email/Slack summary of the full account queue ranked by priority | +| **Rep Feedback Loop** | Every rep action refines scoring weights — the system gets smarter over time | +| **Suppression & Snooze** | Reps control the noise level; suppression rules prevent irrelevant signals from recurring | +| **Win/Loss Retrospective** | Closed deals are traced back to their contributing signals for model improvement | +| **Account Narrative** | AI-maintained running summary of each account's strategic context, updated with each run | +| **Capability Coverage Heatmap** | View-level insight into which accounts have white space for which products | +| **Prompt + Weight Versioning** | All changes to scoring and prompts are versioned, so output regressions can be diagnosed | +| **Cost-Controlled LLM Usage** | Vector pre-filtering + batching keeps per-account LLM cost under $0.15/day | + +--- + +## 10. Example Output Brief + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎯 OPPORTUNITY BRIEF +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Account: Meridian Health Systems +Tier: Strategic +Assigned: Sarah K. (sarah.k@yourco.com) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +OPPORTUNITY + Workflow Automation Suite — Revenue Cycle Expansion + +HEADLINE + CFO signaling process automation push following system merger; + 3 new operations director hires suggest accelerated build-out. + +CONFIDENCE: 91% URGENCY: HIGH SCORE: 0.87 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +CONTRIBUTING SIGNALS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1] LinkedIn Post — CFO Robert Vasquez (Jun 19) + "Post-merger, our biggest unlock is eliminating the manual + handoffs between clinical and revenue cycle. We're building + toward a fully automated claim-to-cash process by Q4." + Engagement: 218 likes, 61 comments + +[2] Job Postings — 3 postings (Jun 10–17) + "Director of Revenue Cycle Operations" × 2 + "VP, Claims Automation" × 1 + All three mention "reduce manual intervention" and + "automate prior authorization workflows" + +[3] Press Release (Jun 5) + Meridian completed merger with NorthStar Medical Group, + adding 4 hospitals and ~$800M in annual revenue. Integration + expected to take 18 months. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +TALKING POINTS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +1. Post-merger claim-to-cash automation is exactly where our + Workflow Automation Suite drives the fastest ROI — clients + avg 40% reduction in manual touchpoints within 90 days. + +2. The NorthStar merger likely doubled their prior auth volume + overnight. Our PA automation module has handled 10M+ + authorizations monthly for comparable health systems. + +3. They're hiring for roles that will own this decision — + now is the time to shape the requirements before vendors + are evaluated. + +4. We already have an integration with their current EHR + (Epic) — no rip-and-replace risk. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +SUGGESTED OUTREACH +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Target: Robert Vasquez, CFO (LinkedIn; not yet in CRM) + Diana Torres, SVP Revenue Cycle (in CRM — last contact 4mo ago) + +Opener: + "Robert — your post about claim-to-cash automation resonated. + We helped [Comparable Health System] cut manual handoffs by + half after their own merger. Given the NorthStar integration + timing, worth a 20-minute call before you finalize your + vendor evaluation?" + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[✅ Act on This] [⏰ Snooze 2 Weeks] [❌ Not Relevant] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +--- + +*Document Version 1.0 — Enterprise Sales Prospecting Agent Loop*