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 <noreply@anthropic.com>
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Secrets — never commit
|
||||
**/.env
|
||||
.env
|
||||
|
||||
# Generated artifacts / local state
|
||||
**/output/
|
||||
sales-agent/output/
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
35
README.md
Normal file
35
README.md
Normal file
@@ -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
|
||||
```
|
||||
33
sales-agent/.env.example
Normal file
33
sales-agent/.env.example
Normal file
@@ -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
|
||||
4
sales-agent/.gitignore
vendored
Normal file
4
sales-agent/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
output/
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
165
sales-agent/README.md
Normal file
165
sales-agent/README.md
Normal file
@@ -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 <BRIEF_ID> --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.
|
||||
```
|
||||
82
sales-agent/data/accounts.json
Normal file
82
sales-agent/data/accounts.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
858
sales-agent/data/capabilities.json
Normal file
858
sales-agent/data/capabilities.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
144
sales-agent/data/capabilities.placeholder.json
Normal file
144
sales-agent/data/capabilities.placeholder.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
97
sales-agent/data/fixtures.json
Normal file
97
sales-agent/data/fixtures.json
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
115
sales-agent/run.py
Normal file
115
sales-agent/run.py
Normal file
@@ -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())
|
||||
1
sales-agent/src/__init__.py
Normal file
1
sales-agent/src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Enterprise Sales Prospecting Agent Loop — MVP (v1, placeholder assumptions)."""
|
||||
0
sales-agent/src/agents/__init__.py
Normal file
0
sales-agent/src/agents/__init__.py
Normal file
16
sales-agent/src/agents/harvester.py
Normal file
16
sales-agent/src/agents/harvester.py
Normal file
@@ -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
|
||||
22
sales-agent/src/agents/normalizer.py
Normal file
22
sales-agent/src/agents/normalizer.py
Normal file
@@ -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
|
||||
115
sales-agent/src/agents/relevance.py
Normal file
115
sales-agent/src/agents/relevance.py
Normal file
@@ -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
|
||||
95
sales-agent/src/agents/scorer.py
Normal file
95
sales-agent/src/agents/scorer.py
Normal file
@@ -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
|
||||
161
sales-agent/src/agents/synthesizer.py
Normal file
161
sales-agent/src/agents/synthesizer.py
Normal file
@@ -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
|
||||
61
sales-agent/src/capabilities.py
Normal file
61
sales-agent/src/capabilities.py
Normal file
@@ -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)
|
||||
103
sales-agent/src/config.py
Normal file
103
sales-agent/src/config.py
Normal file
@@ -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)
|
||||
21
sales-agent/src/connectors/__init__.py
Normal file
21
sales-agent/src/connectors/__init__.py
Normal file
@@ -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, {})
|
||||
39
sales-agent/src/connectors/crm.py
Normal file
39
sales-agent/src/connectors/crm.py
Normal file
@@ -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']}"
|
||||
)
|
||||
168
sales-agent/src/connectors/exa.py
Normal file
168
sales-agent/src/connectors/exa.py
Normal file
@@ -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
|
||||
24
sales-agent/src/connectors/jobs.py
Normal file
24
sales-agent/src/connectors/jobs.py
Normal file
@@ -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
|
||||
23
sales-agent/src/connectors/linkedin.py
Normal file
23
sales-agent/src/connectors/linkedin.py
Normal file
@@ -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
|
||||
35
sales-agent/src/connectors/news.py
Normal file
35
sales-agent/src/connectors/news.py
Normal file
@@ -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)
|
||||
0
sales-agent/src/delivery/__init__.py
Normal file
0
sales-agent/src/delivery/__init__.py
Normal file
60
sales-agent/src/delivery/digest.py
Normal file
60
sales-agent/src/delivery/digest.py
Normal file
@@ -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
|
||||
93
sales-agent/src/delivery/render.py
Normal file
93
sales-agent/src/delivery/render.py
Normal file
@@ -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}")
|
||||
105
sales-agent/src/llm.py
Normal file
105
sales-agent/src/llm.py
Normal file
@@ -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
|
||||
0
sales-agent/src/memory/__init__.py
Normal file
0
sales-agent/src/memory/__init__.py
Normal file
121
sales-agent/src/memory/feedback.py
Normal file
121
sales-agent/src/memory/feedback.py
Normal file
@@ -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
|
||||
121
sales-agent/src/memory/store.py
Normal file
121
sales-agent/src/memory/store.py
Normal file
@@ -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
|
||||
95
sales-agent/src/pipeline.py
Normal file
95
sales-agent/src/pipeline.py
Normal file
@@ -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
|
||||
109
sales-agent/src/types.py
Normal file
109
sales-agent/src/types.py
Normal file
@@ -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)
|
||||
884
sales-prospecting-agent-loop.md
Normal file
884
sales-prospecting-agent-loop.md
Normal file
@@ -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<RawSignal[]> {
|
||||
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<RawSignal[]>).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<string, unknown>;
|
||||
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<Capability[]> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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*
|
||||
Reference in New Issue
Block a user