refactor(stage-3): add Keep-Alive connection pool for Ollama, implement offline lemma resolution in dictionary
This commit is contained in:
@@ -8,6 +8,34 @@ from app.database import get_db, get_cached_sentence_translation, save_sentence_
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 = {
|
COMMON_STOPWORDS = {
|
||||||
"a", "an", "the", "in", "on", "at", "to", "for", "of", "with", "by", "from",
|
"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}"
|
source = f"ollama:{config.OLLAMA_MODEL}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
client = get_ollama_client()
|
||||||
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
|
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
raw_response = data.get("response", "{}")
|
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:
|
try:
|
||||||
parsed = json.loads(raw_response)
|
parsed = json.loads(clean_raw.strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
# In case LLM emitted markdown wrapper ```json ... ```
|
parsed = {}
|
||||||
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 = {}
|
|
||||||
|
|
||||||
sentence_ru = str(parsed.get("sentence_ru", "")).strip()
|
sentence_ru = str(parsed.get("sentence_ru", "")).strip()
|
||||||
|
|
||||||
# Extract word meaning flexibly
|
# Extract word meaning flexibly
|
||||||
raw_meaning = (
|
raw_meaning = (
|
||||||
parsed.get("word_meaning")
|
parsed.get("word_meaning")
|
||||||
or parsed.get(f"{clean_word}_meaning")
|
or parsed.get(f"{clean_word}_meaning")
|
||||||
or parsed.get("meaning")
|
or parsed.get("meaning")
|
||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
raw_meaning = str(raw_meaning).strip()
|
raw_meaning = str(raw_meaning).strip()
|
||||||
|
|
||||||
# Clean up format like "briefcase: портфель" or "портфель (сущ.)"
|
# Clean up format like "briefcase: портфель" or "портфель (сущ.)"
|
||||||
if ":" in raw_meaning:
|
if ":" in raw_meaning:
|
||||||
raw_meaning = raw_meaning.split(":", 1)[1].strip()
|
raw_meaning = raw_meaning.split(":", 1)[1].strip()
|
||||||
|
|
||||||
# If model returned the English term back, fallback to dictionary hint
|
# If model returned the English term back, fallback to dictionary hint
|
||||||
if raw_meaning.lower() == clean_word.lower() or not raw_meaning:
|
if raw_meaning.lower() == clean_word.lower() or not raw_meaning:
|
||||||
if dict_hint:
|
if dict_hint:
|
||||||
raw_meaning = dict_hint.split(",")[0].strip()
|
raw_meaning = dict_hint.split(",")[0].strip()
|
||||||
else:
|
else:
|
||||||
raw_meaning = ""
|
raw_meaning = ""
|
||||||
|
|
||||||
word_meaning = raw_meaning
|
word_meaning = raw_meaning
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
|
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
||||||
logger.warning(f"Ollama connection error ({err_msg})")
|
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]:
|
async def get_ollama_status() -> Dict[str, Any]:
|
||||||
"""Checks Ollama server connectivity and lists installed models."""
|
"""Checks Ollama server connectivity and lists installed models."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
client = get_ollama_client()
|
||||||
resp = await client.get(f"{config.OLLAMA_BASE_URL}/api/tags")
|
resp = await client.get(f"{config.OLLAMA_BASE_URL}/api/tags", timeout=3.0)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
models = [m.get("name") for m in data.get("models", [])]
|
models = [m.get("name") for m in data.get("models", [])]
|
||||||
return {
|
return {
|
||||||
"online": True,
|
"online": True,
|
||||||
"models": models,
|
"models": models,
|
||||||
"active_model": config.OLLAMA_MODEL,
|
"active_model": config.OLLAMA_MODEL,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {
|
return {
|
||||||
@@ -328,16 +356,16 @@ async def get_ollama_status() -> Dict[str, Any]:
|
|||||||
async def warmup_ollama_model() -> bool:
|
async def warmup_ollama_model() -> bool:
|
||||||
"""Preloads the configured Ollama model into RAM with keep_alive to eliminate cold-start latency."""
|
"""Preloads the configured Ollama model into RAM with keep_alive to eliminate cold-start latency."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
client = get_ollama_client()
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{config.OLLAMA_BASE_URL}/api/generate",
|
f"{config.OLLAMA_BASE_URL}/api/generate",
|
||||||
json={"model": config.OLLAMA_MODEL, "keep_alive": "24h"},
|
json={"model": config.OLLAMA_MODEL, "keep_alive": "24h"},
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
logger.info(f"Ollama model '{config.OLLAMA_MODEL}' warmed up successfully (keep_alive=24h).")
|
logger.info(f"Ollama model '{config.OLLAMA_MODEL}' warmed up successfully (keep_alive=24h).")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Ollama warmup returned HTTP {resp.status_code}: {resp.text}")
|
logger.warning(f"Ollama warmup returned HTTP {resp.status_code}: {resp.text}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
||||||
logger.warning(f"Ollama warmup deferred ({err_msg})")
|
logger.warning(f"Ollama warmup deferred ({err_msg})")
|
||||||
@@ -539,29 +567,29 @@ async def generate_lesson_ai_debrief(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=25.0) as client:
|
client = get_ollama_client()
|
||||||
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
|
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=25.0)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
raw_response = resp.json().get("response", "{}")
|
raw_response = resp.json().get("response", "{}")
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
clean_raw = raw_response.strip()
|
clean_raw = raw_response.strip()
|
||||||
if clean_raw.startswith("```json"):
|
if clean_raw.startswith("```json"):
|
||||||
clean_raw = clean_raw[7:]
|
clean_raw = clean_raw[7:]
|
||||||
if clean_raw.startswith("```"):
|
if clean_raw.startswith("```"):
|
||||||
clean_raw = clean_raw[3:]
|
clean_raw = clean_raw[3:]
|
||||||
if clean_raw.endswith("```"):
|
if clean_raw.endswith("```"):
|
||||||
clean_raw = clean_raw[:-3]
|
clean_raw = clean_raw[:-3]
|
||||||
data = json.loads(clean_raw.strip())
|
data = json.loads(clean_raw.strip())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"grade": data.get("grade", "A" if total_score >= 75 else "B"),
|
"grade": data.get("grade", "A" if total_score >= 75 else "B"),
|
||||||
"score": total_score,
|
"score": total_score,
|
||||||
"headline": data.get("headline", "Отличная сессия!"),
|
"headline": data.get("headline", "Отличная сессия!"),
|
||||||
"strengths": data.get("strengths", ["Хорошая работа на уроке"]),
|
"strengths": data.get("strengths", ["Хорошая работа на уроке"]),
|
||||||
"error_patterns": data.get("error_patterns", []),
|
"error_patterns": data.get("error_patterns", []),
|
||||||
"tomorrow_focus": data.get("tomorrow_focus", ["Продолжайте ежедневные занятия"]),
|
"tomorrow_focus": data.get("tomorrow_focus", ["Продолжайте ежедневные занятия"]),
|
||||||
"source": f"ollama:{config.OLLAMA_MODEL}",
|
"source": f"ollama:{config.OLLAMA_MODEL}",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Ollama debrief generation deferred to deterministic engine: {e}")
|
logger.warning(f"Ollama debrief generation deferred to deterministic engine: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -303,9 +303,29 @@ async def lookup_term(query: str) -> Dict[str, Any]:
|
|||||||
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
(query_clean,)
|
(query_clean,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
lemma_found = None
|
||||||
|
if not off_row and len(words) == 1:
|
||||||
|
try:
|
||||||
|
from app.phrasal import lemmatize_word
|
||||||
|
for cand in lemmatize_word(query_clean):
|
||||||
|
if cand.lower() != query_clean.lower():
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
|
(cand,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
off_row = row
|
||||||
|
lemma_found = row["term"]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if off_row:
|
if off_row:
|
||||||
raw_trans = off_row["translation"] or ""
|
raw_trans = off_row["translation"] or ""
|
||||||
clean_trans = clean_russian_translation(raw_trans)
|
clean_trans = clean_russian_translation(raw_trans)
|
||||||
|
if lemma_found and lemma_found.lower() != query_clean.lower():
|
||||||
|
clean_trans = f"{clean_trans} (от {lemma_found})"
|
||||||
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
|
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
|
||||||
res = {
|
res = {
|
||||||
"query": query_clean,
|
"query": query_clean,
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,7 @@ from app.reader import (
|
|||||||
fetch_and_parse_rfc, parse_rfc_text, fetch_and_parse_wikipedia,
|
fetch_and_parse_rfc, parse_rfc_text, fetch_and_parse_wikipedia,
|
||||||
extract_wikipedia_title_and_lang
|
extract_wikipedia_title_and_lang
|
||||||
)
|
)
|
||||||
from app.ai import translate_sentence_ai, get_ollama_status, warmup_ollama_model
|
from app.ai import translate_sentence_ai, get_ollama_status, warmup_ollama_model, close_ollama_client
|
||||||
from app.phrasal import detect_phrasal_verb
|
from app.phrasal import detect_phrasal_verb
|
||||||
from app.cefr import (
|
from app.cefr import (
|
||||||
get_cefr_level_summaries,
|
get_cefr_level_summaries,
|
||||||
@@ -97,6 +97,7 @@ async def lifespan(app: FastAPI):
|
|||||||
asyncio.create_task(prewarm_all_listening_audio())
|
asyncio.create_task(prewarm_all_listening_audio())
|
||||||
yield
|
yield
|
||||||
await close_http_client()
|
await close_http_client()
|
||||||
|
await close_ollama_client()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
Reference in New Issue
Block a user