Anthropic OAuth LLM, entitlements (feature 1), renewals, + 7 enhancements
- LLM: Anthropic Sonnet 5 (effort=low) via OAuth Bearer (Claude Code keychain token, expiry-aware); NVIDIA/LM Studio endpoints kept commented for switch-back. - Entitlements: scripts/import_entitlements.py joins ACL export to accounts by normalized parent name (324/342 matched; ID column Excel-corrupted), maps products->capabilities via data/product_capability_map.json. Raw ACL csv and entitlements.json are gitignored (confidential); accounts.json carries derived currentProducts + ownedCapabilityIds. Owned capabilities tag briefs EXPANSION. - Renewals: local renewal-proximity score boost (window 365d, max +0.10) + 'renewals' report; briefs/digests show RENEWAL WINDOW context. - Batch runs: loop --limit/--rep with persistent cursor (strategic + stale-first). - Digests: per-rep HTML export; timestamped digest_<date>_<time>.md kept forever. - Account narrative memory fed into relevance/synthesis; synthesis maintains it. - Signal quality gate drops PR fluff pre-LLM; SEC EDGAR filings connector. - Citation verification: talking points must cite [S#] signals or are dropped. - Brief filenames use account-name slug; whitespace report + installed-base view. - USAGE.md: full command/flag/env reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
139
sales-agent/src/connectors/sec_edgar.py
Normal file
139
sales-agent/src/connectors/sec_edgar.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""SEC EDGAR filings harvester (free, no API key, no rate-limit pressure).
|
||||
|
||||
For public accounts, recent 8-K/10-K/10-Q filings are the highest-authority
|
||||
signal source available: 8-K items announce material events (acquisitions,
|
||||
leadership changes, results) within days of occurrence.
|
||||
|
||||
Approach:
|
||||
1. Company -> CIK via SEC's company_tickers.json (downloaded once, cached).
|
||||
2. Recent filings via the submissions API (data.sec.gov).
|
||||
3. Emit each recent filing (within EXA_LOOKBACK_DAYS) as a RawSignal.
|
||||
|
||||
SEC fair-access policy: identify with a UA string; <=10 req/s (we do ~1/account).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .. import config
|
||||
from ..types import RawSignal
|
||||
|
||||
_UA = {"User-Agent": "BSG-Prospecting-Loop chris.olson (christopher.olson@gmail.com)"}
|
||||
_TICKER_CACHE = config.STATE_DIR / "sec_company_tickers.json"
|
||||
_CIK_MAP_CACHE = config.STATE_DIR / "sec_cik_map.json"
|
||||
|
||||
# Filing forms worth surfacing, with a plain-language description.
|
||||
_FORMS = {
|
||||
"8-K": "material event (results, M&A, leadership change, agreements)",
|
||||
"10-K": "annual report (strategy, risk factors, IT/infrastructure spend)",
|
||||
"10-Q": "quarterly report",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> dict:
|
||||
req = urllib.request.Request(url, headers=_UA)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _norm_name(name: str) -> str:
|
||||
n = re.sub(r"[^a-z0-9 ]", "", name.lower())
|
||||
n = re.sub(r"\b(inc|corp|corporation|co|company|companies|holdings?|group|plc|ltd|llc|the)\b", "", n)
|
||||
return re.sub(r"\s+", " ", n).strip()
|
||||
|
||||
|
||||
def _ticker_table() -> list[dict]:
|
||||
"""SEC's full company->CIK table, cached locally (refreshed weekly)."""
|
||||
config.ensure_dirs()
|
||||
if _TICKER_CACHE.exists():
|
||||
age_days = (datetime.now(timezone.utc).timestamp() - _TICKER_CACHE.stat().st_mtime) / 86400
|
||||
if age_days < 7:
|
||||
return json.loads(_TICKER_CACHE.read_text())
|
||||
data = _fetch_json("https://www.sec.gov/files/company_tickers.json")
|
||||
rows = [{"cik": v["cik_str"], "ticker": v["ticker"], "name": v["title"]} for v in data.values()]
|
||||
_TICKER_CACHE.write_text(json.dumps(rows))
|
||||
return rows
|
||||
|
||||
|
||||
def _resolve_cik(account: dict) -> int | None:
|
||||
"""Best-effort account name -> CIK match; result (incl. misses) is cached."""
|
||||
cache = json.loads(_CIK_MAP_CACHE.read_text()) if _CIK_MAP_CACHE.exists() else {}
|
||||
aid = account["accountId"]
|
||||
if aid in cache:
|
||||
return cache[aid] # may be None (known non-public / unmatched)
|
||||
target = _norm_name(account["accountName"])
|
||||
cik = None
|
||||
if target:
|
||||
for row in _ticker_table():
|
||||
if _norm_name(row["name"]) == target:
|
||||
cik = row["cik"]
|
||||
break
|
||||
if cik is None: # relaxed: target is a prefix of the SEC name
|
||||
for row in _ticker_table():
|
||||
if _norm_name(row["name"]).startswith(target) and len(target) >= 5:
|
||||
cik = row["cik"]
|
||||
break
|
||||
cache[aid] = cik
|
||||
_CIK_MAP_CACHE.write_text(json.dumps(cache))
|
||||
return cik
|
||||
|
||||
|
||||
def harvest(account: dict) -> list[RawSignal]:
|
||||
if not config.SEC_ENABLED:
|
||||
return []
|
||||
try:
|
||||
cik = _resolve_cik(account)
|
||||
except Exception as e:
|
||||
print(f" [sec] ticker table unavailable: {str(e)[:80]}")
|
||||
return []
|
||||
if cik is None:
|
||||
return [] # not a public company (or unmatched) — cached, no repeat lookups
|
||||
|
||||
try:
|
||||
subs = _fetch_json(f"https://data.sec.gov/submissions/CIK{cik:010d}.json")
|
||||
except Exception as e:
|
||||
print(f" [sec] submissions fetch failed for {account['accountName']}: {str(e)[:80]}")
|
||||
return []
|
||||
|
||||
recent = subs.get("filings", {}).get("recent", {})
|
||||
forms = recent.get("form", [])
|
||||
dates = recent.get("filingDate", [])
|
||||
accs = recent.get("accessionNumber", [])
|
||||
docs = recent.get("primaryDocument", [])
|
||||
items_col = recent.get("items", [])
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=config.EXA_LOOKBACK_DAYS)
|
||||
|
||||
out: list[RawSignal] = []
|
||||
for i, form in enumerate(forms):
|
||||
if form not in _FORMS or len(out) >= config.SEC_MAX_FILINGS_PER_ACCOUNT:
|
||||
continue
|
||||
try:
|
||||
fdate = datetime.strptime(dates[i], "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if fdate < cutoff:
|
||||
continue
|
||||
acc_no = accs[i].replace("-", "")
|
||||
url = f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc_no}/{docs[i]}"
|
||||
items = items_col[i] if i < len(items_col) else ""
|
||||
content = (
|
||||
f"{account['accountName']} filed a {form} with the SEC on {dates[i]} — "
|
||||
f"{_FORMS[form]}."
|
||||
)
|
||||
if items:
|
||||
content += f" 8-K items reported: {items}."
|
||||
out.append(RawSignal(
|
||||
account_id=account["accountId"],
|
||||
source="sec_filing",
|
||||
signal_type=f"sec_{form.lower().replace('-', '')}",
|
||||
content=content,
|
||||
published_at=f"{dates[i]}T00:00:00Z",
|
||||
url=url,
|
||||
extra={"title": f"SEC {form} filing ({dates[i]})", "form": form, "items": items},
|
||||
))
|
||||
if out:
|
||||
print(f" [sec] {len(out)} recent filing(s) for {account['accountName']} (CIK {cik})")
|
||||
return out
|
||||
Reference in New Issue
Block a user