feat: Ollama und Modell vor dem Start prüfen, kurze Meldung statt Traceback

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 19:20:46 +02:00
commit 6aa4887f22
7 changed files with 105 additions and 11 deletions

View file

@ -21,6 +21,8 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar:
- für den Download: [yt-dlp](https://github.com/yt-dlp/yt-dlp) (`pip install yt-dlp`) - für den Download: [yt-dlp](https://github.com/yt-dlp/yt-dlp) (`pip install yt-dlp`)
- Ollama auf `127.0.0.1:11434` mit dem Modell `qwen3:32b` - Ollama auf `127.0.0.1:11434` mit dem Modell `qwen3:32b`
(`ollama pull qwen3:32b`) (`ollama pull qwen3:32b`)
`rede_korrigieren.py` und `glossar_vorschlag.py` prüfen beides vor dem Start und
brechen sonst mit einem Hinweis ab (`sudo systemctl start ollama` bzw. `ollama pull …`)
Für gute Geschwindigkeit muss das Modell komplett in den Grafikspeicher passen. Für gute Geschwindigkeit muss das Modell komplett in den Grafikspeicher passen.
Mit `OLLAMA_NUM_PARALLEL=1` braucht `qwen3:32b` bei 16k Kontext rund 24 GB Mit `OLLAMA_NUM_PARALLEL=1` braucht `qwen3:32b` bei 16k Kontext rund 24 GB

View file

@ -9,11 +9,11 @@ import hashlib
import io import io
import json import json
import re import re
import urllib.request
from aenderungen import compare, read_raw from aenderungen import compare, read_raw
from rede_korrigieren import ( from rede_korrigieren import (
MODEL, apply_replacements, chat_request, read_glossary, split_chunks, MODEL, apply_replacements, chat_request, check_ollama, ollama_open, read_glossary,
split_chunks,
) )
SYSTEM = """Du suchst Hörfehler in der automatischen Abschrift einer deutschen Rede. SYSTEM = """Du suchst Hörfehler in der automatischen Abschrift einer deutschen Rede.
@ -77,7 +77,7 @@ def suggest_chunk(chunk, model):
], ],
"options": {"temperature": 0, "num_ctx": 16384, "num_predict": 4096}, "options": {"temperature": 0, "num_ctx": 16384, "num_predict": 4096},
} }
with urllib.request.urlopen(chat_request(payload), timeout=600) as response: with ollama_open(chat_request(payload)) as response:
result = json.load(response) result = json.load(response)
try: try:
entries = json.loads(result["message"]["content"])["eintraege"] entries = json.loads(result["message"]["content"])["eintraege"]
@ -227,6 +227,7 @@ def main():
with redirect_stdout(io.StringIO()): with redirect_stdout(io.StringIO()):
text = apply_replacements(text, replacements) text = apply_replacements(text, replacements)
check_ollama(args.model)
cache_dir = args.output.with_suffix(".parts") cache_dir = args.output.with_suffix(".parts")
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
suggestions = model_suggestions(text, args.model, cache_dir, known) suggestions = model_suggestions(text, args.model, cache_dir, known)

View file

@ -3,3 +3,5 @@ testpaths = tests
pythonpath = . pythonpath = .
# Project has no async code; skip the globally installed asyncio plugin # Project has no async code; skip the globally installed asyncio plugin
addopts = -p no:asyncio addopts = -p no:asyncio
markers =
real_ollama_check: run the real Ollama server check (urlopen still faked)

View file

@ -8,6 +8,7 @@ import re
import shutil import shutil
import sys import sys
import time import time
import urllib.error
import urllib.request import urllib.request
from aenderungen import REVIEW_MARK, print_summary, report_path, write_report from aenderungen import REVIEW_MARK, print_summary, report_path, write_report
@ -17,6 +18,7 @@ from yt_untertitel import YT_DLP, base_language, default_name, original_language
MODEL = "qwen3:32b" MODEL = "qwen3:32b"
OLLAMA_URL = "http://127.0.0.1:11434/api/chat" OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
OLLAMA_TAGS_URL = "http://127.0.0.1:11434/api/tags"
CHUNK_SIZE = 1200 # Zielgröße eines Abschnitts in Wörtern CHUNK_SIZE = 1200 # Zielgröße eines Abschnitts in Wörtern
MAX_EXTRA_WORDS = 300 # so weit darf ein Abschnitt bis zum nächsten Satzende wachsen MAX_EXTRA_WORDS = 300 # so weit darf ein Abschnitt bis zum nächsten Satzende wachsen
@ -237,6 +239,31 @@ def chat_request(payload):
) )
def ollama_open(request, timeout=600):
"""urlopen() for Ollama; a missing server ends the program with a short message."""
try:
return urllib.request.urlopen(request, timeout=timeout)
except urllib.error.HTTPError:
raise
except urllib.error.URLError as error:
raise SystemExit(
f"Ollama nicht erreichbar ({error.reason}). Läuft der Dienst? "
"Starten mit: sudo systemctl start ollama (oder: ollama serve)"
) from None
def check_ollama(model):
"""Stop early, before any download, if Ollama is down or lacks the model."""
with ollama_open(OLLAMA_TAGS_URL, timeout=10) as response:
installed = {m.get("name") for m in json.load(response).get("models", [])}
wanted = model if ":" in model else f"{model}:latest"
if wanted not in installed:
raise SystemExit(
f"Modell „{model}“ ist in Ollama nicht installiert. "
f"Installieren mit: ollama pull {model}"
)
def correct_chunk(chunk, index, system): def correct_chunk(chunk, index, system):
payload = { payload = {
"model": MODEL, "model": MODEL,
@ -263,7 +290,7 @@ def correct_chunk(chunk, index, system):
words = 0 words = 0
result = {} result = {}
with urllib.request.urlopen(request, timeout=600) as response: with ollama_open(request) as response:
for line in response: for line in response:
if not line.strip(): if not line.strip():
continue continue
@ -328,7 +355,7 @@ def find_paragraph_starts(sentences, first, system):
"num_predict": 2048, "num_predict": 2048,
}, },
} }
with urllib.request.urlopen(chat_request(payload), timeout=600) as response: with ollama_open(chat_request(payload)) as response:
result = json.load(response) result = json.load(response)
content = result["message"]["content"] content = result["message"]["content"]
@ -542,6 +569,7 @@ def main():
# reuses cached parts of another # reuses cached parts of another
MODEL = args.model MODEL = args.model
print(f"Modell: {MODEL}") print(f"Modell: {MODEL}")
check_ollama(MODEL)
raw, target, lang = read_source(args) raw, target, lang = read_source(args)
print(f"Sprache der Rede: {lang}") print(f"Sprache der Rede: {lang}")
parts_dir = target.with_suffix(".parts") parts_dir = target.with_suffix(".parts")

View file

@ -1,4 +1,4 @@
"""Shared fixtures: yt-dlp is always replaced by fakes (no network).""" """Shared fixtures: yt-dlp and Ollama are always replaced by fakes (no network)."""
import json import json
import shutil import shutil
import subprocess import subprocess
@ -7,6 +7,17 @@ from pathlib import Path
import pytest import pytest
@pytest.fixture(autouse=True)
def ollama_available(request, monkeypatch):
"""Skip the Ollama server check; tests marked real_ollama_check test it."""
if "real_ollama_check" in request.keywords:
return
import glossar_vorschlag
import rede_korrigieren
for module in (rede_korrigieren, glossar_vorschlag):
monkeypatch.setattr(module, "check_ollama", lambda model: None)
@pytest.fixture @pytest.fixture
def fake_yt_dlp(): def fake_yt_dlp():
"""Factory for a subprocess.run stand-in. """Factory for a subprocess.run stand-in.

View file

@ -2,6 +2,7 @@
import io import io
import json import json
import sys import sys
import urllib.request
import pytest import pytest
@ -63,7 +64,7 @@ def run_main(monkeypatch, *args):
def test_main_filters_and_formats(raw_file, glossary, monkeypatch, capsys): def test_main_filters_and_formats(raw_file, glossary, monkeypatch, capsys):
model = fake_model(ENTRIES) model = fake_model(ENTRIES)
monkeypatch.setattr(gv.urllib.request, "urlopen", model) monkeypatch.setattr(urllib.request, "urlopen", model)
run_main(monkeypatch, "-i", raw_file, "-g", glossary, "-m", "test:1b") run_main(monkeypatch, "-i", raw_file, "-g", glossary, "-m", "test:1b")
draft = raw_file.with_name("rede.glossar.entwurf.txt").read_text(encoding="utf-8") draft = raw_file.with_name("rede.glossar.entwurf.txt").read_text(encoding="utf-8")
@ -84,12 +85,12 @@ def test_main_filters_and_formats(raw_file, glossary, monkeypatch, capsys):
assert "Vermutete Hörfehler: 2 (1 sicher, 1 unsicher)" in out assert "Vermutete Hörfehler: 2 (1 sicher, 1 unsicher)" in out
# Second run uses the cache # Second run uses the cache
monkeypatch.setattr(gv.urllib.request, "urlopen", no_model_calls) monkeypatch.setattr(urllib.request, "urlopen", no_model_calls)
run_main(monkeypatch, "-i", raw_file, "-g", glossary, "-m", "test:1b") run_main(monkeypatch, "-i", raw_file, "-g", glossary, "-m", "test:1b")
def test_correction_changes_are_commented_out(raw_file, glossary, tmp_path, monkeypatch): def test_correction_changes_are_commented_out(raw_file, glossary, tmp_path, monkeypatch):
monkeypatch.setattr(gv.urllib.request, "urlopen", fake_model(ENTRIES[:2])) monkeypatch.setattr(urllib.request, "urlopen", fake_model(ENTRIES[:2]))
corrected = tmp_path / "rede.korrigiert.txt" corrected = tmp_path / "rede.korrigiert.txt"
# Corrected without glossary: "Kremmel" became "Kremml" (a model error) # Corrected without glossary: "Kremmel" became "Kremml" (a model error)
corrected.write_text( corrected.write_text(
@ -110,7 +111,7 @@ def test_correction_changes_are_commented_out(raw_file, glossary, tmp_path, monk
def test_drafted_entries_are_valid_glossary(raw_file, monkeypatch, tmp_path): def test_drafted_entries_are_valid_glossary(raw_file, monkeypatch, tmp_path):
monkeypatch.setattr(gv.urllib.request, "urlopen", fake_model(ENTRIES[:2])) monkeypatch.setattr(urllib.request, "urlopen", fake_model(ENTRIES[:2]))
out = tmp_path / "entwurf.txt" out = tmp_path / "entwurf.txt"
run_main(monkeypatch, "-i", raw_file, "-o", out) run_main(monkeypatch, "-i", raw_file, "-o", out)
assert rk.read_glossary(out) == ([], []) # nothing active assert rk.read_glossary(out) == ([], []) # nothing active
@ -126,7 +127,7 @@ def test_unreadable_model_answer(raw_file, monkeypatch, tmp_path, capsys):
def urlopen(request, timeout=None): def urlopen(request, timeout=None):
return io.BytesIO(json.dumps({"message": {"content": "kein JSON"}}).encode()) return io.BytesIO(json.dumps({"message": {"content": "kein JSON"}}).encode())
monkeypatch.setattr(gv.urllib.request, "urlopen", urlopen) monkeypatch.setattr(urllib.request, "urlopen", urlopen)
run_main(monkeypatch, "-i", raw_file, "-o", tmp_path / "e.txt") run_main(monkeypatch, "-i", raw_file, "-o", tmp_path / "e.txt")
assert "unlesbar" in capsys.readouterr().out assert "unlesbar" in capsys.readouterr().out

View file

@ -566,3 +566,52 @@ def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
run_main(monkeypatch, *args) run_main(monkeypatch, *args)
assert exc.value.code == 2 assert exc.value.code == 2
assert message in capsys.readouterr().err assert message in capsys.readouterr().err
# === Ollama availability ===
def tags_urlopen(*names):
body = json.dumps({"models": [{"name": n} for n in names]}).encode("utf-8")
return fake_urlopen(body)
@pytest.mark.real_ollama_check
@pytest.mark.parametrize("model", ["qwen3:32b", "llama3"])
def test_check_ollama_model_installed(monkeypatch, model):
monkeypatch.setattr(rk.urllib.request, "urlopen", tags_urlopen("qwen3:32b", "llama3:latest"))
rk.check_ollama(model)
@pytest.mark.real_ollama_check
def test_check_ollama_model_missing(monkeypatch):
monkeypatch.setattr(rk.urllib.request, "urlopen", tags_urlopen("qwen3:8b"))
with pytest.raises(SystemExit, match="ollama pull qwen3:32b"):
rk.check_ollama("qwen3:32b")
def refused(request, timeout=None):
raise rk.urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))
@pytest.mark.real_ollama_check
def test_main_ollama_down_stops_before_download(fake_yt_dlp, yt_dlp_installed, monkeypatch):
run = fake_yt_dlp(VIDEO, vtt=VTT)
monkeypatch.setattr(subprocess, "run", run)
monkeypatch.setattr(rk.urllib.request, "urlopen", refused)
with pytest.raises(SystemExit, match="Ollama nicht erreichbar.*systemctl start ollama"):
run_main(monkeypatch, "-u", URL)
assert run.calls == []
def test_correct_chunk_ollama_gone_midway(monkeypatch):
monkeypatch.setattr(rk.urllib.request, "urlopen", refused)
with pytest.raises(SystemExit, match="Ollama nicht erreichbar"):
rk.correct_chunk("Text.", 1, "system")
def test_ollama_open_passes_http_errors(monkeypatch):
def not_found(request, timeout=None):
raise rk.urllib.error.HTTPError(rk.OLLAMA_URL, 404, "Not Found", {}, None)
monkeypatch.setattr(rk.urllib.request, "urlopen", not_found)
with pytest.raises(rk.urllib.error.HTTPError):
rk.ollama_open(rk.OLLAMA_URL)