refactor(stage-3): add Keep-Alive connection pool for Ollama, implement offline lemma resolution in dictionary

This commit is contained in:
2026-10-07 14:45:05 +03:00
parent 9072fc365a
commit 4e1f809018
3 changed files with 131 additions and 82 deletions
+109 -81
View File
@@ -8,6 +8,34 @@ from app.database import get_db, get_cached_sentence_translation, save_sentence_
logger = logging.getLogger(__name__)
_ollama_client: Optional[httpx.AsyncClient] = None
def get_ollama_client() -> httpx.AsyncClient:
"""
Returns a shared singleton httpx.AsyncClient instance for Ollama
with connection pooling and Keep-Alive.
"""
global _ollama_client
if _ollama_client is None or _ollama_client.is_closed:
limits = httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=120.0)
timeout = httpx.Timeout(connect=3.0, read=60.0, write=5.0, pool=5.0)
_ollama_client = httpx.AsyncClient(limits=limits, timeout=timeout)
return _ollama_client
async def close_ollama_client():
"""Closes the shared singleton Ollama HTTP client on application shutdown."""
global _ollama_client
if _ollama_client is not None:
client = _ollama_client
_ollama_client = None
if not client.is_closed:
try:
await client.aclose()
except Exception:
pass
COMMON_STOPWORDS = {
"a", "an", "the", "in", "on", "at", "to", "for", "of", "with", "by", "from",
@@ -227,53 +255,53 @@ async def translate_sentence_ai(
source = f"ollama:{config.OLLAMA_MODEL}"
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
if resp.status_code == 200:
data = resp.json()
raw_response = data.get("response", "{}")
client = get_ollama_client()
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
if resp.status_code == 200:
data = resp.json()
raw_response = data.get("response", "{}")
try:
parsed = json.loads(raw_response)
except Exception:
# In case LLM emitted markdown wrapper ```json ... ```
clean_raw = raw_response.strip()
if clean_raw.startswith("```json"):
clean_raw = clean_raw[7:]
if clean_raw.startswith("```"):
clean_raw = clean_raw[3:]
if clean_raw.endswith("```"):
clean_raw = clean_raw[:-3]
try:
parsed = json.loads(raw_response)
parsed = json.loads(clean_raw.strip())
except Exception:
# In case LLM emitted markdown wrapper ```json ... ```
clean_raw = raw_response.strip()
if clean_raw.startswith("```json"):
clean_raw = clean_raw[7:]
if clean_raw.startswith("```"):
clean_raw = clean_raw[3:]
if clean_raw.endswith("```"):
clean_raw = clean_raw[:-3]
try:
parsed = json.loads(clean_raw.strip())
except Exception:
parsed = {}
parsed = {}
sentence_ru = str(parsed.get("sentence_ru", "")).strip()
sentence_ru = str(parsed.get("sentence_ru", "")).strip()
# Extract word meaning flexibly
raw_meaning = (
parsed.get("word_meaning")
or parsed.get(f"{clean_word}_meaning")
or parsed.get("meaning")
or ""
)
raw_meaning = str(raw_meaning).strip()
# Extract word meaning flexibly
raw_meaning = (
parsed.get("word_meaning")
or parsed.get(f"{clean_word}_meaning")
or parsed.get("meaning")
or ""
)
raw_meaning = str(raw_meaning).strip()
# Clean up format like "briefcase: портфель" or "портфель (сущ.)"
if ":" in raw_meaning:
raw_meaning = raw_meaning.split(":", 1)[1].strip()
# Clean up format like "briefcase: портфель" or "портфель (сущ.)"
if ":" in raw_meaning:
raw_meaning = raw_meaning.split(":", 1)[1].strip()
# If model returned the English term back, fallback to dictionary hint
if raw_meaning.lower() == clean_word.lower() or not raw_meaning:
if dict_hint:
raw_meaning = dict_hint.split(",")[0].strip()
else:
raw_meaning = ""
# If model returned the English term back, fallback to dictionary hint
if raw_meaning.lower() == clean_word.lower() or not raw_meaning:
if dict_hint:
raw_meaning = dict_hint.split(",")[0].strip()
else:
raw_meaning = ""
word_meaning = raw_meaning
word_meaning = raw_meaning
else:
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
else:
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
except Exception as e:
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
logger.warning(f"Ollama connection error ({err_msg})")
@@ -306,16 +334,16 @@ async def translate_sentence_ai(
async def get_ollama_status() -> Dict[str, Any]:
"""Checks Ollama server connectivity and lists installed models."""
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(f"{config.OLLAMA_BASE_URL}/api/tags")
if resp.status_code == 200:
data = resp.json()
models = [m.get("name") for m in data.get("models", [])]
return {
"online": True,
"models": models,
"active_model": config.OLLAMA_MODEL,
}
client = get_ollama_client()
resp = await client.get(f"{config.OLLAMA_BASE_URL}/api/tags", timeout=3.0)
if resp.status_code == 200:
data = resp.json()
models = [m.get("name") for m in data.get("models", [])]
return {
"online": True,
"models": models,
"active_model": config.OLLAMA_MODEL,
}
except Exception:
pass
return {
@@ -328,16 +356,16 @@ async def get_ollama_status() -> Dict[str, Any]:
async def warmup_ollama_model() -> bool:
"""Preloads the configured Ollama model into RAM with keep_alive to eliminate cold-start latency."""
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{config.OLLAMA_BASE_URL}/api/generate",
json={"model": config.OLLAMA_MODEL, "keep_alive": "24h"},
)
if resp.status_code == 200:
logger.info(f"Ollama model '{config.OLLAMA_MODEL}' warmed up successfully (keep_alive=24h).")
return True
else:
logger.warning(f"Ollama warmup returned HTTP {resp.status_code}: {resp.text}")
client = get_ollama_client()
resp = await client.post(
f"{config.OLLAMA_BASE_URL}/api/generate",
json={"model": config.OLLAMA_MODEL, "keep_alive": "24h"},
)
if resp.status_code == 200:
logger.info(f"Ollama model '{config.OLLAMA_MODEL}' warmed up successfully (keep_alive=24h).")
return True
else:
logger.warning(f"Ollama warmup returned HTTP {resp.status_code}: {resp.text}")
except Exception as e:
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
logger.warning(f"Ollama warmup deferred ({err_msg})")
@@ -539,29 +567,29 @@ async def generate_lesson_ai_debrief(
}
try:
async with httpx.AsyncClient(timeout=25.0) as client:
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
if resp.status_code == 200:
raw_response = resp.json().get("response", "{}")
# Parse JSON
clean_raw = raw_response.strip()
if clean_raw.startswith("```json"):
clean_raw = clean_raw[7:]
if clean_raw.startswith("```"):
clean_raw = clean_raw[3:]
if clean_raw.endswith("```"):
clean_raw = clean_raw[:-3]
data = json.loads(clean_raw.strip())
client = get_ollama_client()
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=25.0)
if resp.status_code == 200:
raw_response = resp.json().get("response", "{}")
# Parse JSON
clean_raw = raw_response.strip()
if clean_raw.startswith("```json"):
clean_raw = clean_raw[7:]
if clean_raw.startswith("```"):
clean_raw = clean_raw[3:]
if clean_raw.endswith("```"):
clean_raw = clean_raw[:-3]
data = json.loads(clean_raw.strip())
return {
"grade": data.get("grade", "A" if total_score >= 75 else "B"),
"score": total_score,
"headline": data.get("headline", "Отличная сессия!"),
"strengths": data.get("strengths", ["Хорошая работа на уроке"]),
"error_patterns": data.get("error_patterns", []),
"tomorrow_focus": data.get("tomorrow_focus", ["Продолжайте ежедневные занятия"]),
"source": f"ollama:{config.OLLAMA_MODEL}",
}
return {
"grade": data.get("grade", "A" if total_score >= 75 else "B"),
"score": total_score,
"headline": data.get("headline", "Отличная сессия!"),
"strengths": data.get("strengths", ["Хорошая работа на уроке"]),
"error_patterns": data.get("error_patterns", []),
"tomorrow_focus": data.get("tomorrow_focus", ["Продолжайте ежедневные занятия"]),
"source": f"ollama:{config.OLLAMA_MODEL}",
}
except Exception as e:
logger.warning(f"Ollama debrief generation deferred to deterministic engine: {e}")