Improve signal quality (Exa news category) and remove mock fallback

Exa harvester:
- EXA_CATEGORY=news (default) restricts to news articles, excluding evergreen
  careers/marketing landing pages that were producing irrelevant signals.
- Event-focused query (acquisition/earnings/launch/leadership), dropped
  hiring/career terms that pulled recruiting pages.
- Domain scoping now OFF by default (broad-web news about the account beats
  first-party careers pages); add EXA_EXCLUDE_DOMAINS and loop --domain-filter.

LLM layer:
- Remove the mock reasoner entirely (no provider => loop refuses to run).
- On HTTP 429, WAIT (LLM_RATELIMIT_WAIT_SECONDS, default 900s; honors
  Retry-After) and retry up to LLM_RATELIMIT_MAX_RETRIES (24) instead of
  degrading to mock output.
- If the LLM is ultimately unavailable for an account, skip it and leave its
  signals unconsumed (retried next run) — never fabricate briefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 10:41:41 -04:00
parent 2374ef137d
commit 9131e83c08
9 changed files with 169 additions and 173 deletions

View File

@@ -28,12 +28,13 @@ def _load_dotenv() -> None:
_load_dotenv()
# --- LLM ---
# Provider abstraction: openai | gemini | anthropic | mock. If LLM_PROVIDER is
# unset we auto-detect from whichever key is present (OpenAI-compatible first),
# falling back to a deterministic mock reasoner so the loop still runs offline.
# Provider abstraction: openai | gemini | anthropic. If LLM_PROVIDER is unset we
# auto-detect from whichever key is present (OpenAI-compatible first). There is NO
# mock fallback: the loop refuses to run without a configured provider, and on
# rate limits it WAITS and retries rather than emitting low-quality output.
#
# "openai" is any OpenAI-compatible /chat/completions endpoint — OpenAI, OpenRouter,
# Together, Groq, vLLM, Ollama, LM Studio, etc. — configured by three env vars:
# Together, Groq, NVIDIA, vLLM, Ollama, LM Studio, etc. — configured by:
# OPENAI_BASE_URL (e.g. https://api.openai.com/v1)
# OPENAI_API_KEY
# OPENAI_MODEL (e.g. gpt-4o-mini)
@@ -50,8 +51,13 @@ OPENAI_TEMPERATURE = float(os.environ.get("OPENAI_TEMPERATURE", "0.5"))
OPENAI_FREQUENCY_PENALTY = float(os.environ.get("OPENAI_FREQUENCY_PENALTY", "0.4"))
OPENAI_PRESENCE_PENALTY = float(os.environ.get("OPENAI_PRESENCE_PENALTY", "0.0"))
# Retries when a model returns degenerate/unparseable content (stochastic — a
# retry usually succeeds). Distinct from the network/429 backoff.
# retry usually succeeds).
LLM_CONTENT_RETRIES = int(os.environ.get("LLM_CONTENT_RETRIES", "3"))
# On HTTP 429 (rate limit) we WAIT this long and retry (no mock fallback). Honors
# a Retry-After response header when present. Lets the loop grind slowly but
# correctly through a tight rate limit instead of degrading quality.
LLM_RATELIMIT_WAIT_SECONDS = int(os.environ.get("LLM_RATELIMIT_WAIT_SECONDS", "900"))
LLM_RATELIMIT_MAX_RETRIES = int(os.environ.get("LLM_RATELIMIT_MAX_RETRIES", "24"))
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip()
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-4-6")
@@ -68,24 +74,22 @@ if not _provider:
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 == "openai" and not OPENAI_API_KEY:
_provider = "mock"
_provider = "none"
# Selecting a provider whose key is missing => no usable provider.
if _provider == "openai" and not OPENAI_API_KEY:
_provider = "none"
elif _provider == "gemini" and not GEMINI_API_KEY:
_provider = "mock"
_provider = "none"
elif _provider == "anthropic" and not ANTHROPIC_API_KEY:
_provider = "mock"
_provider = "none"
LLM_PROVIDER = _provider
USE_MOCK_LLM = LLM_PROVIDER == "mock"
LLM_CONFIGURED = LLM_PROVIDER != "none"
def llm_label() -> str:
if LLM_PROVIDER == "mock":
return "MOCK reasoner (no LLM key)"
if LLM_PROVIDER == "none":
return "NONE (no LLM provider configured)"
if LLM_PROVIDER == "openai":
host = OPENAI_BASE_URL.split("//")[-1].split("/")[0]
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
@@ -111,10 +115,18 @@ 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"
# Exa content category. "news" restricts results to news articles (press releases,
# coverage) and keeps out evergreen pages like careers/marketing landing pages —
# the single biggest signal-quality lever. Set to "" to disable category filtering.
EXA_CATEGORY = os.environ.get("EXA_CATEGORY", "news").strip()
# Domain scoping is OFF by default now: with category=news, broad-web search finds
# actual news ABOUT the account from outlets, whereas scoping to the company's own
# domain surfaces careers/marketing pages. Turn on for first-party newsroom only.
EXA_DOMAIN_FILTER = os.environ.get("EXA_DOMAIN_FILTER", "0").strip() == "1"
# Hosts to always exclude (comma-separated) — e.g. careers/talent subdomains.
EXA_EXCLUDE_DOMAINS = [
d.strip() for d in os.environ.get("EXA_EXCLUDE_DOMAINS", "").split(",") if d.strip()
]
# --- Dedup / suppression windows ---
SEMANTIC_DEDUP_DAYS = 90