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:
1 parent
b38edd4ca9
commit
eed4ec0b62
3 files changed
+49
-5
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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,16 +181,14 @@ 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:
|
||||
req = urllib.request.Request(
|
||||
f"{host}/api/generate",
|
||||
data=json.dumps(
|
||||
{
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
@@ -197,7 +198,11 @@ def classify_headline(
|
||||
"num_predict": max_tokens,
|
||||
},
|
||||
}
|
||||
).encode(),
|
||||
if think is not None:
|
||||
body["think"] = think
|
||||
req = urllib.request.Request(
|
||||
f"{host}/api/generate",
|
||||
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
|
||||
Reference in new issue
Block a user