437ceacfc1
Behavior-preserving cleanup (345 tests pass, ruff clean):
- scheduler: replace 62 inline logger.x(json.dumps({...})) calls with a
_log_event helper, and collapse 11 identical _job_runtime dicts into an
_idle_runtime() factory over _JOB_NAMES.
- settings: add app/services/settings_store.py (get_setting/get_value/get_map/
upsert_setting) and route ~13 hand-rolled SystemSetting queries + two
identical _settings_map helpers through it.
- scoring.get_rankings: collapse the per-ticker N+1 (3-4 queries + a commit each)
into 2 bulk reads + a single conditional commit; drop the redundant re-fetch.
Lazy recompute-on-read is preserved. Adds first tests for get_rankings.
Net ~ -245 lines across the touched modules.
211 lines
7.5 KiB
Python
211 lines
7.5 KiB
Python
"""Runtime-configurable sentiment provider.
|
|
|
|
Lets an admin switch the sentiment LLM (provider, model, API key) at runtime
|
|
via SystemSetting, without a redeploy. Precedence for each value:
|
|
DB setting > environment variable > hardcoded default.
|
|
|
|
The API key is write-only: it is persisted but never returned through any
|
|
read path. Reads report only whether a key is configured and its source.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.exceptions import ProviderError, ValidationError
|
|
from app.services import settings_store
|
|
from app.services.admin_service import update_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VALID_PROVIDERS = {"openai", "gemini", "deepseek", "xai", "openai_compatible"}
|
|
|
|
PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
|
"openai": "gpt-4o-mini",
|
|
"gemini": "gemini-2.0-flash",
|
|
"deepseek": "deepseek-chat",
|
|
"xai": "grok-3",
|
|
"openai_compatible": "",
|
|
}
|
|
|
|
# Fixed base URLs for named OpenAI-compatible providers.
|
|
PROVIDER_BASE_URLS: dict[str, str] = {
|
|
"deepseek": "https://api.deepseek.com",
|
|
"xai": "https://api.x.ai/v1",
|
|
}
|
|
|
|
# Providers grounded in live web search. The rest score from model knowledge.
|
|
# xAI and OpenAI ground via the Responses API web-search tool; Gemini via its
|
|
# own search grounding.
|
|
WEB_SEARCH_PROVIDERS = {"openai", "gemini", "xai"}
|
|
|
|
# Providers needing a user-supplied base URL (generic compatible endpoints).
|
|
CUSTOM_BASE_URL_PROVIDERS = {"openai_compatible"}
|
|
|
|
# SystemSetting keys
|
|
KEY_PROVIDER = "sentiment_provider"
|
|
KEY_MODEL = "sentiment_model"
|
|
KEY_API_KEY = "sentiment_api_key"
|
|
KEY_BASE_URL = "sentiment_base_url"
|
|
|
|
|
|
def _env_key_for(provider: str) -> str:
|
|
if provider == "openai":
|
|
return settings.openai_api_key or ""
|
|
if provider == "gemini":
|
|
return settings.gemini_api_key or ""
|
|
if provider == "deepseek":
|
|
return getattr(settings, "deepseek_api_key", "") or ""
|
|
if provider == "xai":
|
|
return getattr(settings, "xai_api_key", "") or ""
|
|
return ""
|
|
|
|
|
|
def _env_model_for(provider: str) -> str:
|
|
if provider == "openai":
|
|
return settings.openai_model or PROVIDER_DEFAULT_MODELS["openai"]
|
|
if provider == "gemini":
|
|
return settings.gemini_model or PROVIDER_DEFAULT_MODELS["gemini"]
|
|
return PROVIDER_DEFAULT_MODELS.get(provider, "")
|
|
|
|
|
|
def _base_url_for(provider: str, stored_base_url: str) -> str:
|
|
if provider in PROVIDER_BASE_URLS:
|
|
return PROVIDER_BASE_URLS[provider]
|
|
return stored_base_url
|
|
|
|
|
|
async def _resolve(db: AsyncSession) -> dict:
|
|
"""Resolve effective config from DB > env > default."""
|
|
stored = await settings_store.get_map(db, [KEY_PROVIDER, KEY_MODEL, KEY_API_KEY, KEY_BASE_URL])
|
|
|
|
provider = (stored.get(KEY_PROVIDER) or "").strip().lower()
|
|
if provider not in VALID_PROVIDERS:
|
|
provider = "openai" if settings.openai_api_key or not settings.gemini_api_key else "gemini"
|
|
|
|
model = (stored.get(KEY_MODEL) or "").strip() or _env_model_for(provider)
|
|
base_url = _base_url_for(provider, (stored.get(KEY_BASE_URL) or "").strip())
|
|
|
|
db_key = (stored.get(KEY_API_KEY) or "").strip()
|
|
if db_key:
|
|
api_key, source = db_key, "database"
|
|
elif _env_key_for(provider):
|
|
api_key, source = _env_key_for(provider), "environment"
|
|
else:
|
|
api_key, source = "", "none"
|
|
|
|
return {
|
|
"provider": provider,
|
|
"model": model,
|
|
"base_url": base_url,
|
|
"api_key": api_key,
|
|
"api_key_source": source,
|
|
}
|
|
|
|
|
|
async def get_sentiment_config(db: AsyncSession) -> dict:
|
|
"""Public config — never includes the raw API key."""
|
|
r = await _resolve(db)
|
|
return {
|
|
"provider": r["provider"],
|
|
"model": r["model"],
|
|
"base_url": r["base_url"],
|
|
"api_key_configured": bool(r["api_key"]),
|
|
"api_key_source": r["api_key_source"],
|
|
"web_search": r["provider"] in WEB_SEARCH_PROVIDERS,
|
|
"valid_providers": sorted(VALID_PROVIDERS),
|
|
"default_models": PROVIDER_DEFAULT_MODELS,
|
|
"web_search_providers": sorted(WEB_SEARCH_PROVIDERS),
|
|
"custom_base_url_providers": sorted(CUSTOM_BASE_URL_PROVIDERS),
|
|
}
|
|
|
|
|
|
async def update_sentiment_config(
|
|
db: AsyncSession,
|
|
provider: str | None = None,
|
|
model: str | None = None,
|
|
api_key: str | None = None,
|
|
base_url: str | None = None,
|
|
) -> dict:
|
|
"""Persist config. An empty/omitted api_key leaves the stored key untouched."""
|
|
if provider is not None:
|
|
provider = provider.strip().lower()
|
|
if provider not in VALID_PROVIDERS:
|
|
raise ValidationError(
|
|
f"Unknown sentiment provider '{provider}'. Valid: {', '.join(sorted(VALID_PROVIDERS))}"
|
|
)
|
|
await update_setting(db, KEY_PROVIDER, provider)
|
|
|
|
if model is not None:
|
|
model = model.strip()
|
|
if model:
|
|
await update_setting(db, KEY_MODEL, model)
|
|
|
|
if base_url is not None:
|
|
await update_setting(db, KEY_BASE_URL, base_url.strip())
|
|
|
|
if api_key: # only overwrite when a non-empty key is supplied
|
|
await update_setting(db, KEY_API_KEY, api_key.strip())
|
|
|
|
return await get_sentiment_config(db)
|
|
|
|
|
|
async def build_sentiment_provider(db: AsyncSession):
|
|
"""Construct the active sentiment provider from current config.
|
|
|
|
Raises ProviderError if credentials/base_url are missing.
|
|
"""
|
|
r = await _resolve(db)
|
|
provider, model, base_url, api_key = r["provider"], r["model"], r["base_url"], r["api_key"]
|
|
if not api_key:
|
|
raise ProviderError(f"No API key configured for sentiment provider '{provider}'")
|
|
|
|
if provider == "openai":
|
|
from app.providers.openai_sentiment import OpenAISentimentProvider
|
|
return OpenAISentimentProvider(api_key, model)
|
|
if provider == "gemini":
|
|
from app.providers.gemini_sentiment import GeminiSentimentProvider
|
|
return GeminiSentimentProvider(api_key, model)
|
|
if provider == "xai":
|
|
# xAI grounds via the Responses API web_search tool (the former Live
|
|
# Search / search_parameters API is deprecated).
|
|
from app.providers.openai_sentiment import OpenAISentimentProvider
|
|
return OpenAISentimentProvider(
|
|
api_key, model, base_url=base_url, tool_type="web_search", source="xai",
|
|
)
|
|
if provider in {"deepseek", "openai_compatible"}:
|
|
if not base_url:
|
|
raise ProviderError(f"No base_url configured for sentiment provider '{provider}'")
|
|
from app.providers.openai_compatible_sentiment import OpenAICompatibleSentimentProvider
|
|
return OpenAICompatibleSentimentProvider(api_key, model, base_url, source=provider)
|
|
|
|
raise ProviderError(f"Unsupported sentiment provider '{provider}'")
|
|
|
|
|
|
async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dict:
|
|
"""Build the active provider and fetch one ticker as a live credentials check."""
|
|
r = await _resolve(db)
|
|
try:
|
|
prov = await build_sentiment_provider(db)
|
|
data = await prov.fetch_sentiment(ticker.strip().upper())
|
|
return {
|
|
"ok": True,
|
|
"provider": r["provider"],
|
|
"model": r["model"],
|
|
"ticker": ticker.strip().upper(),
|
|
"classification": data.classification,
|
|
"confidence": data.confidence,
|
|
"reasoning": data.reasoning or None,
|
|
}
|
|
except Exception as exc:
|
|
logger.warning("Sentiment provider test failed: %s", exc)
|
|
return {
|
|
"ok": False,
|
|
"provider": r["provider"],
|
|
"model": r["model"],
|
|
"error": str(exc),
|
|
}
|