""" ForexBot2 — DeepSeek LLM client ================================ Thin requests-based client for DeepSeek's OpenAI-compatible chat API, used by `news_engine` for display/warning-only enrichment (headline sentiment, event surprise direction, market summary). Signals are NEVER gated on LLM output — every caller has a non-LLM fallback and treats a `None` return as "use the fallback". Design constraints: - One attempt per call, no retries: news runs inside scan cycles; the news engine's 600 s context cache is the natural retry cadence. - A UTC-day request cap bounds spend even if a bug causes call storms. - Cost: DeepSeek caches identical prompt PREFIXES server-side (cache-hit input is ~50x cheaper), so callers pass a STATIC system prompt (no timestamps or interpolation) and put all variable content in the user payload. This is a module-level singleton (`get_llm_client`) because the daily cap counter must survive the per-scan reconstruction of `NewsEngine`. """ import datetime as _dt import json import logging import threading import time from typing import Any, Dict, Optional, Tuple import requests from app_config import deepseek_api_key, get_app_config logger = logging.getLogger(__name__) DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" DEFAULT_MODEL = "deepseek-v4-flash" # 8s timed out on a measured cold-prefix-cache headline batch (2026-08-03); # warm-cache calls come back well under that. 12s buys cold-start headroom # while still bounding worst-case scan latency (≤3 calls per cache window). DEFAULT_TIMEOUT = 12.0 DEFAULT_DAILY_CAP = 500 class DeepSeekClient: """JSON-in/JSON-out chat wrapper. All failures return None.""" def __init__( self, api_key: str, model: str = DEFAULT_MODEL, timeout: float = DEFAULT_TIMEOUT, daily_cap: int = DEFAULT_DAILY_CAP, enabled: bool = True, ): self._api_key = api_key or "" self._model = model self._timeout = timeout self._daily_cap = daily_cap self._enabled = enabled self._session = requests.Session() self._session.headers.update({ "Content-Type": "application/json", "Authorization": f"Bearer {self._api_key}", }) self._lock = threading.Lock() self._count_day: Optional[_dt.date] = None self._count = 0 self._warned: set = set() # Health stamps for status displays: last successful call and # last failure (ts, kind). Read-only for consumers. self.last_ok: float = 0.0 self.last_error: Optional[Tuple[float, str]] = None @property def available(self) -> bool: return self._enabled and bool(self._api_key) def _warn_once(self, kind: str, msg: str, *args: Any) -> None: if kind in self._warned: logger.debug(msg, *args) else: self._warned.add(kind) logger.warning(msg, *args) def _under_cap(self) -> bool: """Count a request against the UTC-day cap; False = don't call.""" today = _dt.datetime.now(_dt.timezone.utc).date() with self._lock: if self._count_day != today: self._count_day = today self._count = 0 # Clear ALL warn-once kinds, not just "cap": the network/ # http_*/parse flags used to be set once and never reset, # so in a daemon that runs for weeks the second and every # later outage logged at DEBUG only. self._warned.clear() if self._count >= self._daily_cap: self._warn_once( "cap", "DeepSeek daily request cap (%d) exhausted — " "falling back to keyword analysis until UTC midnight", self._daily_cap) return False self._count += 1 return True def chat_json(self, system_prompt: str, user_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: """One chat call; returns the model's JSON object or None.""" if not self.available or not self._under_cap(): return None body = { "model": self._model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": json.dumps(user_payload, sort_keys=True)}, ], "response_format": {"type": "json_object"}, "temperature": 0, # V4-Flash is a hybrid thinking model; without this flag it # burns hundreds of reasoning tokens per call — measured # 2026-08-03: a 25-headline batch blew straight through the # timeout AND could exhaust max_tokens before the answer, # returning empty content. The doctrine + few-shot examples # in the system prompts carry the quality instead. "thinking": {"type": "disabled"}, # Must fit the largest reply: a full sentiment batch is # ~10 tokens per headline; 1000 truncated mid-JSON on a real # RSS cycle (2026-08-03). "max_tokens": 2000, } try: resp = self._session.post( DEEPSEEK_URL, json=body, timeout=self._timeout) except requests.RequestException as e: self.last_error = (time.time(), "network") self._warn_once("network", "DeepSeek call failed: %s", e) return None if resp.status_code != 200: self.last_error = (time.time(), f"http_{resp.status_code}") self._warn_once( f"http_{resp.status_code}", "DeepSeek HTTP %s: %s", resp.status_code, resp.text[:200]) return None try: parsed = self._parse_response(resp.json()) except ValueError: parsed = None if parsed is None: self.last_error = (time.time(), "parse") self._warn_once("parse", "DeepSeek response was not the " "expected JSON object — ignoring") else: self.last_ok = time.time() # A recovered call re-arms the failure warnings so the NEXT # outage is visible at WARNING again. "cap" keeps its own # UTC-day reset in _under_cap. (Taken under the lock; safe # here because chat_json holds no lock — _under_cap has # already returned.) if self._warned - {"cap"}: with self._lock: self._warned = {k for k in self._warned if k == "cap"} return parsed @staticmethod def _parse_response(body: Any) -> Optional[Dict[str, Any]]: """Extract the assistant's JSON object from a chat completion.""" try: content = body["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError): return None try: obj = json.loads(content) except (TypeError, ValueError): return None return obj if isinstance(obj, dict) else None # ── Process-wide singleton ──────────────────────────────────────────── _client: Optional[DeepSeekClient] = None _client_lock = threading.Lock() def get_llm_client() -> DeepSeekClient: global _client with _client_lock: if _client is None: settings = get_app_config().deepseek_settings _client = DeepSeekClient( api_key=deepseek_api_key(), model=str(settings.get("model", DEFAULT_MODEL)), timeout=float(settings.get("timeout_seconds", DEFAULT_TIMEOUT)), daily_cap=int(settings.get("daily_request_cap", DEFAULT_DAILY_CAP)), enabled=settings.get("enabled", True) is not False, ) return _client