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:
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)
|
||||
Reference in New Issue
Block a user