168 lines
6.0 KiB
Python
168 lines
6.0 KiB
Python
import json
|
|
import logging
|
|
from typing import Optional, Dict, Any
|
|
import httpx
|
|
from app import config
|
|
from app.database import get_cached_sentence_translation, save_sentence_translation
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def build_translation_prompt(sentence: str, target_word: str = "") -> str:
|
|
clean_sentence = sentence.strip()
|
|
clean_word = target_word.strip()
|
|
|
|
if clean_word:
|
|
return (
|
|
"You are a professional English-to-Russian translator.\n"
|
|
"Translate the following English sentence into clear, natural, and idiomatic Russian.\n"
|
|
f"Also provide the precise in-context meaning and translation of the target word/phrase '{clean_word}'.\n\n"
|
|
f'Sentence: "{clean_sentence}"\n'
|
|
f'Target word: "{clean_word}"\n\n'
|
|
"Respond ONLY with a valid JSON object in this format:\n"
|
|
"{\n"
|
|
' "sentence_ru": "перевод предложения на русский язык",\n'
|
|
' "word_meaning": "значение слова в этом контексте"\n'
|
|
"}"
|
|
)
|
|
else:
|
|
return (
|
|
"You are a professional English-to-Russian translator.\n"
|
|
"Translate the following English sentence into clear, natural, and idiomatic Russian.\n\n"
|
|
f'Sentence: "{clean_sentence}"\n\n'
|
|
"Respond ONLY with a valid JSON object in this format:\n"
|
|
"{\n"
|
|
' "sentence_ru": "перевод предложения на русский язык",\n'
|
|
' "word_meaning": ""\n'
|
|
"}"
|
|
)
|
|
|
|
|
|
async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[str, Any]:
|
|
"""
|
|
Translates a sentence using local AI (Ollama), with SQLite instant-cache lookup.
|
|
Returns:
|
|
{
|
|
"original_sentence": "...",
|
|
"sentence_ru": "...",
|
|
"target_word": "...",
|
|
"word_meaning": "...",
|
|
"source": "ollama:model" | "cache" | "fallback",
|
|
"cached": bool
|
|
}
|
|
"""
|
|
clean_sentence = sentence.strip()
|
|
clean_word = target_word.strip()
|
|
|
|
if not clean_sentence:
|
|
return {
|
|
"original_sentence": "",
|
|
"sentence_ru": "",
|
|
"target_word": clean_word,
|
|
"word_meaning": "",
|
|
"source": "none",
|
|
"cached": False,
|
|
}
|
|
|
|
# 1. Fast SQLite Cache Lookup (0.01 ms)
|
|
cached = get_cached_sentence_translation(clean_sentence, clean_word)
|
|
if cached:
|
|
return {
|
|
"original_sentence": clean_sentence,
|
|
"sentence_ru": cached["translated_text"],
|
|
"target_word": clean_word,
|
|
"word_meaning": cached.get("word_meaning") or "",
|
|
"source": cached.get("source") or "cache",
|
|
"cached": True,
|
|
}
|
|
|
|
# 2. Local AI Generation (Ollama)
|
|
prompt = build_translation_prompt(clean_sentence, clean_word)
|
|
payload = {
|
|
"model": config.OLLAMA_MODEL,
|
|
"prompt": prompt,
|
|
"stream": False,
|
|
"format": "json",
|
|
"options": {
|
|
"temperature": 0.2,
|
|
"num_predict": 256,
|
|
},
|
|
}
|
|
|
|
sentence_ru = ""
|
|
word_meaning = ""
|
|
source = f"ollama:{config.OLLAMA_MODEL}"
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.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", "{}")
|
|
try:
|
|
parsed = json.loads(raw_response)
|
|
sentence_ru = parsed.get("sentence_ru", "").strip()
|
|
word_meaning = parsed.get("word_meaning", "").strip()
|
|
except Exception:
|
|
# In case LLM emitted unescaped markdown or wrapper
|
|
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]
|
|
parsed = json.loads(clean_raw.strip())
|
|
sentence_ru = parsed.get("sentence_ru", "").strip()
|
|
word_meaning = parsed.get("word_meaning", "").strip()
|
|
else:
|
|
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
|
|
except Exception as e:
|
|
logger.warning(f"Ollama connection error: {e}")
|
|
|
|
# 3. Graceful Fallback if Ollama is unreachable
|
|
if not sentence_ru:
|
|
sentence_ru = "(Локальный ИИ временно недоступен)"
|
|
source = "fallback"
|
|
|
|
# Save to SQLite Cache if valid translation obtained
|
|
if sentence_ru and not sentence_ru.startswith("("):
|
|
save_sentence_translation(
|
|
sentence=clean_sentence,
|
|
translated_text=sentence_ru,
|
|
target_word=clean_word,
|
|
word_meaning=word_meaning,
|
|
source=source,
|
|
)
|
|
|
|
return {
|
|
"original_sentence": clean_sentence,
|
|
"sentence_ru": sentence_ru,
|
|
"target_word": clean_word,
|
|
"word_meaning": word_meaning,
|
|
"source": source,
|
|
"cached": False,
|
|
}
|
|
|
|
|
|
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,
|
|
}
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"online": False,
|
|
"models": [],
|
|
"active_model": config.OLLAMA_MODEL,
|
|
}
|