Без Ollama приложение больше не выглядит зависшим

Проверка работы с недоступной Ollama показала: если машина с ней просто
выключена, пакеты уходят в никуда и каждый вызов честно ждёт свой таймаут.
Замер: запись одной вещи — 92 секунды, разбор фразы — 154, правка — 30.
Формально всё работало, но пользоваться этим невозможно.

Предохранитель в ollama.py:
- перед дорогим запросом быстрая проверка TCP-соединения (1.5 с вместо 120);
- после неудачи 30 секунд к серверу вообще не обращаемся;
- при успехе результат кешируется на 5 секунд, чтобы не проверять на каждом
  шаге;
- смена адреса в настройках и проба адреса сбрасывают паузу, иначе новый
  сервер полминуты считался бы мёртвым.

Замер после: те же операции отвечают мгновенно, первая проверка — 1.5 с.
Проверено в двух режимах недоступности: закрытый порт и молчащий адрес.

Тесты закрепляют поведение: запись, разбор фразы и поиск при мёртвом адресе
обязаны укладываться в 3 секунды и работать по существу — вещь записывается
без тегов, фраза разбирается регулярками, поиск идёт по словам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 17:56:46 +03:00
co-authored by Claude Opus 5
parent 5926967270
commit 781690365e
4 changed files with 143 additions and 6 deletions
+11 -2
View File
@@ -185,8 +185,9 @@ ollama pull bge-m3
## Что если Ollama выключен ## Что если Ollama выключен
Если Ollama на отдельной машине, она бывает выключена. Приложение это переживает: **Приложение работает и без Ollama вообще** — можно поставить и пользоваться,
наверху появляется предупреждение, а работает почти всё. не устанавливая её. Наверху появится предупреждение со ссылкой в настройки,
а всё основное останется на месте.
| | Ollama есть | Ollama нет | | | Ollama есть | Ollama нет |
|---|---|---| |---|---|---|
@@ -196,6 +197,14 @@ ollama pull bge-m3
| Разбор свободных фраз | модель | упрощённо, регулярками | | Разбор свободных фраз | модель | упрощённо, регулярками |
| Поиск по смыслу | да | нет | | Поиск по смыслу | да | нет |
**Ожидания это не добавляет.** Если машина с Ollama выключена, пакеты уходят
в никуда, и без защиты каждый вызов честно ждал бы свой таймаут: замер показал
92 секунды на запись одной вещи и 154 на разбор фразы — выглядит как зависание.
Поэтому в `ollama.py` стоит предохранитель: перед дорогим запросом идёт быстрая
проверка соединения (полторы секунды вместо двух минут), а после неудачи
приложение полминуты вообще не тревожит сервер. Замер после: те же операции
отвечают мгновенно. Смена адреса в настройках сбрасывает паузу сразу.
Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`). Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`).
Не `omnicoder-9b`: тот ризонер, пишет `<think>` прямо в ответ и медленнее. Не `omnicoder-9b`: тот ризонер, пишет `<think>` прямо в ответ и медленнее.
+5
View File
@@ -81,10 +81,12 @@ def api_config_probe():
was = config.OLLAMA_HOST was = config.OLLAMA_HOST
try: try:
config.OLLAMA_HOST = host.rstrip("/") config.OLLAMA_HOST = host.rstrip("/")
ollama.reset_breaker() # проверяем именно этот адрес, без памяти о прошлом
alive = ollama.check() alive = ollama.check()
models = ollama.models() if alive else [] models = ollama.models() if alive else []
finally: finally:
config.OLLAMA_HOST = was # проба не должна менять настройки config.OLLAMA_HOST = was # проба не должна менять настройки
ollama.reset_breaker()
if not alive: if not alive:
return jsonify({"ok": False, "models": [], return jsonify({"ok": False, "models": [],
@@ -107,6 +109,9 @@ def api_config_save():
if not changed: if not changed:
return jsonify({"ok": True, "reply": "Менять нечего."}) return jsonify({"ok": True, "reply": "Менять нечего."})
# Адрес поменяли — прошлая неудача больше ничего не значит, иначе новый
# сервер полминуты считался бы мёртвым.
ollama.reset_breaker()
alive = ollama.check() alive = ollama.check()
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else
"Но Ollama по этому адресу молчит.") "Но Ollama по этому адресу молчит.")
+86 -4
View File
@@ -5,7 +5,10 @@
""" """
import json import json
import time
import socket
import logging import logging
import urllib.parse
import urllib.request import urllib.request
# Адрес и модели читаем через `config.X`, а не копией при импорте: их можно # Адрес и модели читаем через `config.X`, а не копией при импорте: их можно
@@ -16,25 +19,95 @@ from .config import (CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS,
logger = logging.getLogger(__name__) 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(): def check():
"""Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI.""" """Доступен ли сервер. Отвечает быстро: это дёргает баннер в UI."""
if not available():
return False
try: try:
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
with urllib.request.urlopen(req, timeout=3) as r: with urllib.request.urlopen(req, timeout=3) as r:
return r.status == 200 if r.status == 200:
_mark_up()
return True
except Exception: except Exception:
return False _mark_down()
return False
def models(): def models():
"""Список установленных моделей (для диагностики).""" """Список установленных моделей (для диагностики)."""
if not available():
return []
try: try:
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags") req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
with urllib.request.urlopen(req, timeout=5) as r: with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read().decode("utf-8")) data = json.loads(r.read().decode("utf-8"))
_mark_up()
return [m.get("model", "") for m in data.get("models", [])] return [m.get("model", "") for m in data.get("models", [])]
except Exception as e: except Exception as e:
_mark_down()
logger.warning("Список моделей не получен: %s", e) logger.warning("Список моделей не получен: %s", e)
return [] return []
@@ -51,6 +124,8 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS):
"stream": False, "stream": False,
"options": {"num_predict": num_predict, "temperature": 0}, "options": {"num_predict": num_predict, "temperature": 0},
} }
if not available():
return None # сервер лежит: не ждём таймаут на каждой записи
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
config.OLLAMA_HOST + "/api/chat", data=data, config.OLLAMA_HOST + "/api/chat", data=data,
@@ -59,8 +134,10 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS):
try: try:
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8")) resp = json.loads(r.read().decode("utf-8"))
_mark_up()
return (resp.get("message") or {}).get("content", "") return (resp.get("message") or {}).get("content", "")
except Exception as e: except Exception as e:
_mark_down()
logger.warning("Запрос к модели %s не удался: %s", model, e) logger.warning("Запрос к модели %s не удался: %s", model, e)
return None return None
@@ -74,6 +151,8 @@ def embed(text, is_query=False):
text = (text or "").strip() text = (text or "").strip()
if not text: if not text:
return None return None
if not available():
return None
prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX
data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8") data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
@@ -82,7 +161,10 @@ def embed(text, is_query=False):
) )
try: try:
with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r: 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: except Exception as e:
_mark_down()
logger.warning("Эмбеддинг не получен: %s", e) logger.warning("Эмбеддинг не получен: %s", e)
return None return None
+41
View File
@@ -177,6 +177,47 @@ config.DB_FILE.write_text("{ битый json", encoding="utf-8")
r.check("битая база не роняет", c.get("/api/tree").status_code == 200) r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" ")) 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 ==") print("\n== Батники: только ASCII ==")
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже # cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки # в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки