diff --git a/app/ai.py b/app/ai.py index fedf09f..7a8a6bd 100644 --- a/app/ai.py +++ b/app/ai.py @@ -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}") diff --git a/app/dictionary.py b/app/dictionary.py index cc7deb3..d267f65 100644 --- a/app/dictionary.py +++ b/app/dictionary.py @@ -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", (query_clean,) ).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: raw_trans = off_row["translation"] or "" 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()] res = { "query": query_clean, diff --git a/app/main.py b/app/main.py index 0fbdd45..212463b 100644 --- a/app/main.py +++ b/app/main.py @@ -36,7 +36,7 @@ from app.reader import ( fetch_and_parse_rfc, parse_rfc_text, fetch_and_parse_wikipedia, 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.cefr import ( get_cefr_level_summaries, @@ -97,6 +97,7 @@ async def lifespan(app: FastAPI): asyncio.create_task(prewarm_all_listening_audio()) yield await close_http_client() + await close_ollama_client() app = FastAPI(