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