diff --git a/config.py b/config.py index 1bf0b20..f7fa651 100644 --- a/config.py +++ b/config.py @@ -183,6 +183,9 @@ class OllamaConfig(BaseModel): timeout_seconds: int = 30 max_tokens: int = 256 temperature: float = 0.3 + # Ollama "think" flag. None = not sent (models without a thinking mode). qwen3 thinks by default, and with + # format=json Ollama then returns an empty "{}", so set OLLAMA_THINK=false for qwen3. + think: bool | None = None # News/catalyst enrichment (advisory layer). enrichment_enabled: bool = False enrichment_interval_seconds: int = 120 @@ -227,6 +230,8 @@ class OllamaConfig(BaseModel): timeout_seconds=i("OLLAMA_TIMEOUT_SECONDS", 30), max_tokens=i("OLLAMA_MAX_TOKENS", 256), temperature=f("OLLAMA_TEMPERATURE", 0.3), + think=(None if not (v.get("OLLAMA_THINK") or "").strip() + else v["OLLAMA_THINK"].strip().lower() in {"1", "true", "yes", "on"}), enrichment_enabled=b("NEWS_ENRICH_ENABLED", False), enrichment_interval_seconds=i("NEWS_ENRICH_INTERVAL_SECONDS", 120), enrichment_lookback_hours=i("NEWS_ENRICH_LOOKBACK_HOURS", 12), diff --git a/research/ingestion/news_enrichment.py b/research/ingestion/news_enrichment.py index 2725882..17c4be2 100644 --- a/research/ingestion/news_enrichment.py +++ b/research/ingestion/news_enrichment.py @@ -149,6 +149,9 @@ def _parse(raw: str, fallback_text: str = "") -> CatalystAnalysis | None: return None if not isinstance(data, dict): return None + if not {"catalyst_type", "sentiment", "conviction", "is_dilutive"} & set(data): + # e.g. "{}" from a thinking model under format=json: a failure, not a confident "other" + return None ctype = str(data.get("catalyst_type", "other")).strip().lower() if ctype not in CATALYST_TYPES: ctype = "other" @@ -178,26 +181,28 @@ def classify_headline( timeout: int = 30, temperature: float = 0.3, max_tokens: int = 256, + think: bool | None = None, ) -> CatalystAnalysis | None: """Ask a local Ollama model to classify a headline. None on any failure.""" if not (headline or snippet): return None prompt = build_prompt(headline, snippet, tickers) try: + body = { + "model": model, + "prompt": prompt, + "stream": False, + "format": "json", + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } + if think is not None: + body["think"] = think req = urllib.request.Request( f"{host}/api/generate", - data=json.dumps( - { - "model": model, - "prompt": prompt, - "stream": False, - "format": "json", - "options": { - "temperature": temperature, - "num_predict": max_tokens, - }, - } - ).encode(), + data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=timeout) as resp: @@ -306,6 +311,7 @@ def enrich_recent_news(con, cfg, lookback_hours: int | None = None, limit: int | title or "", snippet or "", raw_tickers or "", host=cfg.host, model=cfg.model, timeout=cfg.timeout_seconds, temperature=cfg.temperature, max_tokens=cfg.max_tokens, + think=getattr(cfg, "think", None), ) if analysis is None: # Ollama down / unparseable — DON'T poison the cache; retry next pass. diff --git a/tests/unit/research/test_news_enrichment.py b/tests/unit/research/test_news_enrichment.py index c7f469d..5d2fc6e 100644 --- a/tests/unit/research/test_news_enrichment.py +++ b/tests/unit/research/test_news_enrichment.py @@ -221,3 +221,36 @@ def test_catalyst_map_latest_per_symbol(con, monkeypatch): assert "BIO" in m assert m["BIO"]["is_dilutive"] is True assert m["BIO"]["catalyst_type"] == "offering_dilution" + + +def test_parse_empty_object_is_a_failure_not_other(): + # qwen3 with thinking on + format=json answers "{}": that must not read as a confident "other" + assert ne._parse("{}") is None + assert ne._parse('{"rationale": "x"}') is None + + +def test_classify_headline_sends_think_only_when_set(monkeypatch): + sent = [] + + class Resp(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake(req, timeout=None): + sent.append(json.loads(req.data)) + return Resp(json.dumps({"response": '{"catalyst_type": "fda_approval", "sentiment": 0.8, ' + '"conviction": 0.9, "is_dilutive": false}'}).encode()) + + monkeypatch.setattr(ne.urllib.request, "urlopen", fake) + ne.classify_headline("FDA approves X", model="qwen3:14b", think=False) + ne.classify_headline("FDA approves X", model="tools:latest") + assert sent[0]["think"] is False and "think" not in sent[1] + + +def test_ollama_think_env(): + from config import OllamaConfig + assert OllamaConfig.from_env({"OLLAMA_THINK": "false"}).think is False + assert OllamaConfig.from_env({"OLLAMA_THINK": ""}).think is None