diff --git a/README.md b/README.md index b2beeb6..8daa29b 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,9 @@ ollama pull bge-m3 ## Что если Ollama выключен -Если Ollama на отдельной машине, она бывает выключена. Приложение это переживает: -наверху появляется предупреждение, а работает почти всё. +**Приложение работает и без Ollama вообще** — можно поставить и пользоваться, +не устанавливая её. Наверху появится предупреждение со ссылкой в настройки, +а всё основное останется на месте. | | Ollama есть | Ollama нет | |---|---|---| @@ -196,6 +197,14 @@ ollama pull bge-m3 | Разбор свободных фраз | модель | упрощённо, регулярками | | Поиск по смыслу | да | нет | +**Ожидания это не добавляет.** Если машина с Ollama выключена, пакеты уходят +в никуда, и без защиты каждый вызов честно ждал бы свой таймаут: замер показал +92 секунды на запись одной вещи и 154 на разбор фразы — выглядит как зависание. +Поэтому в `ollama.py` стоит предохранитель: перед дорогим запросом идёт быстрая +проверка соединения (полторы секунды вместо двух минут), а после неудачи +приложение полминуты вообще не тревожит сервер. Замер после: те же операции +отвечают мгновенно. Смена адреса в настройках сбрасывает паузу сразу. + Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`). Не `omnicoder-9b`: тот ризонер, пишет `` прямо в ответ и медленнее. diff --git a/hlamingo/app.py b/hlamingo/app.py index 3de41fe..1fce207 100644 --- a/hlamingo/app.py +++ b/hlamingo/app.py @@ -81,10 +81,12 @@ def api_config_probe(): was = config.OLLAMA_HOST try: config.OLLAMA_HOST = host.rstrip("/") + ollama.reset_breaker() # проверяем именно этот адрес, без памяти о прошлом alive = ollama.check() models = ollama.models() if alive else [] finally: config.OLLAMA_HOST = was # проба не должна менять настройки + ollama.reset_breaker() if not alive: return jsonify({"ok": False, "models": [], @@ -107,6 +109,9 @@ def api_config_save(): if not changed: return jsonify({"ok": True, "reply": "Менять нечего."}) + # Адрес поменяли — прошлая неудача больше ничего не значит, иначе новый + # сервер полминуты считался бы мёртвым. + ollama.reset_breaker() alive = ollama.check() reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else "Но Ollama по этому адресу молчит.") diff --git a/hlamingo/ollama.py b/hlamingo/ollama.py index b4ff462..54450e1 100644 --- a/hlamingo/ollama.py +++ b/hlamingo/ollama.py @@ -5,7 +5,10 @@ """ import json +import time +import socket import logging +import urllib.parse import urllib.request # Адрес и модели читаем через `config.X`, а не копией при импорте: их можно @@ -16,25 +19,95 @@ from .config import (CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS, logger = logging.getLogger(__name__) +# ============================================================ +# Предохранитель +# +# Если машина с Ollama просто выключена, пакеты уходят в никуда, и каждый +# запрос честно ждёт свой таймаут. Замер: запись одной вещи занимала 92 секунды, +# разбор фразы — 154. Пользователь решит, что приложение зависло. +# +# Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут), +# а после неудачи — пауза, в течение которой к серверу вообще не ходим. +# ============================================================ +CONNECT_TIMEOUT = 1.5 # столько ждём само соединение +DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи +UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки + +_down_until = 0.0 +_up_until = 0.0 + + +def _host_port(): + parsed = urllib.parse.urlparse(config.OLLAMA_HOST) + return parsed.hostname or "localhost", parsed.port or (443 if parsed.scheme == "https" else 80) + + +def available(): + """Стоит ли вообще пытаться. Быстро и без долгих ожиданий.""" + global _down_until, _up_until + now = time.time() + if now < _up_until: + return True + if now < _down_until: + return False + try: + host, port = _host_port() + socket.create_connection((host, port), timeout=CONNECT_TIMEOUT).close() + _up_until = now + UP_CACHE + _down_until = 0.0 + return True + except Exception: + _down_until = now + DOWN_COOLDOWN + _up_until = 0.0 + return False + + +def _mark_down(): + """Запрос не удался — держим паузу, чтобы не ждать таймаут на каждом шаге.""" + global _down_until, _up_until + _down_until = time.time() + DOWN_COOLDOWN + _up_until = 0.0 + + +def _mark_up(): + global _up_until + _up_until = time.time() + UP_CACHE + + +def reset_breaker(): + """Сбросить паузу — например, когда адрес поменяли в настройках.""" + global _down_until, _up_until + _down_until = 0.0 + _up_until = 0.0 + def check(): - """Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI.""" + """Доступен ли сервер. Отвечает быстро: это дёргает баннер в UI.""" + if not available(): + return False try: req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") with urllib.request.urlopen(req, timeout=3) as r: - return r.status == 200 + if r.status == 200: + _mark_up() + return True except Exception: - return False + _mark_down() + return False def models(): """Список установленных моделей (для диагностики).""" + if not available(): + return [] try: req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") with urllib.request.urlopen(req, timeout=5) as r: data = json.loads(r.read().decode("utf-8")) + _mark_up() return [m.get("model", "") for m in data.get("models", [])] except Exception as e: + _mark_down() logger.warning("Список моделей не получен: %s", e) return [] @@ -51,6 +124,8 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS): "stream": False, "options": {"num_predict": num_predict, "temperature": 0}, } + if not available(): + return None # сервер лежит: не ждём таймаут на каждой записи data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( config.OLLAMA_HOST + "/api/chat", data=data, @@ -59,8 +134,10 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS): try: with urllib.request.urlopen(req, timeout=timeout) as r: resp = json.loads(r.read().decode("utf-8")) + _mark_up() return (resp.get("message") or {}).get("content", "") except Exception as e: + _mark_down() logger.warning("Запрос к модели %s не удался: %s", model, e) return None @@ -74,6 +151,8 @@ def embed(text, is_query=False): text = (text or "").strip() if not text: return None + if not available(): + return None prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8") req = urllib.request.Request( @@ -82,7 +161,10 @@ def embed(text, is_query=False): ) try: with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r: - return json.loads(r.read().decode("utf-8")).get("embedding") + vector = json.loads(r.read().decode("utf-8")).get("embedding") + _mark_up() + return vector except Exception as e: + _mark_down() logger.warning("Эмбеддинг не получен: %s", e) return None diff --git a/tests/test_all.py b/tests/test_all.py index 758548d..62a6b58 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -177,6 +177,47 @@ config.DB_FILE.write_text("{ битый json", encoding="utf-8") r.check("битая база не роняет", c.get("/api/tree").status_code == 200) r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" ")) +print("\n== Работа без Ollama: не ждём таймаутов ==") +# Машина с Ollama бывает выключена, и тогда пакеты уходят в никуда. Без +# предохранителя запись одной вещи ждала 92 секунды, разбор фразы — 154: +# пользователь решит, что приложение зависло. 192.0.2.1 — из блока TEST-NET-1 +# (RFC 5737), он гарантированно не отвечает. +import time as _time # noqa: E402 +_was_host = config.OLLAMA_HOST +try: + config.OLLAMA_HOST = "http://192.0.2.1:11434" + ollama.reset_breaker() + + t0 = _time.time() + alive = ollama.check() + first = _time.time() - t0 + r.check("недоступность видна быстро", not alive and first < 5, "%.1f с" % first) + + t0 = _time.time() + d = j("POST", "/api/put", name="вещь без ии") + put_time = _time.time() - t0 + r.check("запись вещи не ждёт таймаут", d.get("ok") and put_time < 3, + "%.1f с, ответ: %s" % (put_time, d)) + r.check("тегов нет, но вещь записана", + d.get("item", {}).get("aliases") == [], d.get("item")) + + t0 = _time.time() + d = j("POST", "/api/say", text="положил кусачки в коробку D1") + say_time = _time.time() - t0 + r.check("свободная фраза не ждёт таймаут", say_time < 3, "%.1f с" % say_time) + r.check("фраза разобрана регулярками", d.get("kind") == "put", d) + + t0 = _time.time() + d = c.get("/api/find?q=вещь без ии").get_json() + r.check("поиск работает без ИИ", d["items"] and _time.time() - t0 < 3, d) + + r.check("смена адреса сбрасывает паузу", + (ollama.reset_breaker() or True) and ollama._down_until == 0) +finally: + config.OLLAMA_HOST = _was_host + ollama.reset_breaker() +r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True) + print("\n== Батники: только ASCII ==") # cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже # в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки