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:
2026-07-11 19:12:36 -04:00
parent 9131e83c08
commit c2854ba163
23 changed files with 22050 additions and 144 deletions

View 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