From eed4ec0b62f4e8b75bb68f6f11811ad3ebd3ac0c Mon Sep 17 00:00:00 2001 From: momentum dev Date: Thu, 1 Oct 2026 20:59:32 -0500 Subject: [PATCH] fix: catalyst classifier works with qwen3 (OLLAMA_THINK) and no longer hides an empty answer qwen3 thinks by default. With format=json, Ollama then returns "{}", which the parser silently read as a confident "other" (sentiment 0, conviction 0), so every headline scored zero with no error. Two changes: - OLLAMA_THINK (true/false). It is sent as Ollama's "think" flag only when set, so other models are untouched. qwen3:14b needs OLLAMA_THINK=false. - A JSON object with none of the expected keys is now a failure (None, retried next pass), not "other". Checked through the live code path against qwen3:14b on mason (think=false): 1.4 s per headline. 5/5 offerings (including an ATM sales agreement and a PIPE) flagged dilutive. 'Stocks moving' roundups scored no catalyst. Earnings, guidance, analyst, FDA and contract items were labelled correctly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86 --- config.py | 5 ++++ research/ingestion/news_enrichment.py | 30 +++++++++++-------- tests/unit/research/test_news_enrichment.py | 33 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) 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