b831001cfb
Validation (llm_news_eval.py harness + 78-headline golden CSV + verdict doc): headline scoring 78/78 vs keyword baseline 24/78 — validated; event gold-direction calls misread close K/M numeric comparisons ~11% of the time (both claims cases in sample, JOLTS 8.79M vs 8.84M). Fix: event payload now carries code-computed "vs_forecast" (from EconomicEvent.surprise) and the prompt gains an explicit inverted- metrics rule + worked claims example — iteration 1 (field alone) fixed the arithmetic but exposed a masked inversion error. 18/18 on live re-verification (4 misreads fixed, 11 regression cases clean, 3 fresh both-side probes). /status: new "News & AI" section — per-source health for the calendar chain (which of FF/MyFXBook served), each RSS feed, and DeepSeek (daily count, last error kind), written passively by the existing fetch paths under _cache_lock. Failures WARN once on transition (repeats DEBUG, recovery INFO) so the rotating log carries outage stories without 10-min spam. test_all.py: +5 Module 19 tests (351 total); also carries the Module 22 fixed-RR-TP tests from the prior research session (shared file). Replay artifacts dir gitignored like commit_ladder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ
196 lines
7.3 KiB
Python
196 lines
7.3 KiB
Python
"""
|
|
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
|
|
self._warned.discard("cap")
|
|
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()
|
|
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
|
|
|
|
|
|
def reset_llm_client() -> None:
|
|
"""Drop the singleton (tests / config reloads)."""
|
|
global _client
|
|
with _client_lock:
|
|
_client = None
|