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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
This commit is contained in:
momentum devandClaude Opus 5.5 committed 2026-10-01 20:59:32 -05:00
1 parent b38edd4ca9
commit eed4ec0b62
3 files changed
+56 -12

No files matched your search

+5
View File
@@ -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),
+18 -12
View File
@@ -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.
@@ -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