feat(ai): integrate local Ollama AI for in-context sentence translation and caching (Task 13)

This commit is contained in:
Antigravity committed 2026-10-06 15:05:24 +03:00
1 parent 0c5951df8d
commit 37a1f1ab35
12 files changed
+604 -14

No files matched your search

+5 -1
View File
@@ -6,13 +6,17 @@ __pycache__/
venv/
ENV/
# Database
# Database & Local Data
*.sqlite3
*.sqlite
*.db
*.db-journal
*.db-wal
*.db-shm
data/ollama/
data/*.db
data/*.db-wal
data/*.db-shm
# Environment & Secret variables
.env
+7 -7
View File
@@ -288,13 +288,13 @@
---
### 13. 🌐 Контекстный перевод предложений в читалке (In-Context Sentence Translator)
- [ ] Кнопка `🌐 Перевести предложение` в попапе слова читалки (рядом с контекстной цитатой).
- [ ] Интеллектуальный перевод всей фразы целиком с сохранением смысла книги (устраняет ловушку оторванных значений слов вроде *draft*, *address*, *run*).
- [ ] Подсветка значения целевого слова именно в данном предложении.
- [ ] Прямой перевод любого предложения по клику/выделению в тексте без лишних модальных окон.
- [ ] Мгновенный кэш переводов предложений в SQLite (`sentence_translations`) за 0.01 мс.
- [ ] Возможность сохранить правильный контекстный перевод прямо в карточку словаря.
### 13. 🌐 Контекстный перевод предложений в читалке (In-Context Sentence Translator) (Completed)
- [x] Кнопка `🌐 Перевести предложение` в попапе слова читалки (рядом с контекстной цитатой).
- [x] Интеллектуальный перевод всей фразы целиком с сохранением смысла книги через локальный ИИ (устраняет ловушку оторванных значений слов вроде *draft*, *address*, *run*).
- [x] Разбор и точное значение целевого слова именно в данном контексте (`word_meaning`).
- [x] Мгновенный кэш переводов предложений в SQLite (`sentence_translations`) с SHA-256 хешированием за 0.01 мс.
- [x] Возможность сохранить правильный контекстный перевод прямо в карточку словаря («Вставить в карточку»).
- [x] Развёрнут локальный контейнер Ollama с моделями Qwen 2.5 (`qwen2.5:1.5b` и `qwen2.5:3b`) с автоматической выгрузкой из памяти при простое (`OLLAMA_KEEP_ALIVE=5m`) и 100% автономностью без внешних API.
---
+167
View File
@@ -0,0 +1,167 @@
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,
}
+4
View File
@@ -40,3 +40,7 @@ MYMEMORY_EMAIL = os.getenv("MYMEMORY_EMAIL", "admin@qpid.pro")
DB_PATH = Path(os.getenv("DB_PATH", str(BASE_DIR / "data" / "notes.db")))
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
+69
View File
@@ -1,5 +1,6 @@
import sqlite3
import contextlib
import hashlib
from pathlib import Path
from typing import Generator
from app import config
@@ -186,6 +187,21 @@ def _migrate(conn: sqlite3.Connection):
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_offline_dict_term ON offline_dictionary(term COLLATE NOCASE);")
# Ensure sentence_translations table exists
conn.execute("""
CREATE TABLE IF NOT EXISTS sentence_translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sentence_hash TEXT NOT NULL,
original_text TEXT NOT NULL,
translated_text TEXT NOT NULL,
target_word TEXT DEFAULT '',
word_meaning TEXT DEFAULT '',
source TEXT DEFAULT 'local_llm',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentence_trans_hash ON sentence_translations(sentence_hash);")
# Auto-seed offline dictionary if empty
_seed_offline_dictionary(conn)
@@ -333,6 +349,59 @@ def init_db():
pos TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_offline_dict_term ON offline_dictionary(term COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS sentence_translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sentence_hash TEXT NOT NULL,
original_text TEXT NOT NULL,
translated_text TEXT NOT NULL,
target_word TEXT DEFAULT '',
word_meaning TEXT DEFAULT '',
source TEXT DEFAULT 'local_llm',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sentence_trans_hash ON sentence_translations(sentence_hash);
""")
# Apply migrations for pre-existing databases
_migrate(conn)
def compute_sentence_hash(sentence: str, target_word: str = "") -> str:
norm = f"{sentence.strip().lower()}||{target_word.strip().lower()}"
return hashlib.sha256(norm.encode("utf-8")).hexdigest()
def get_cached_sentence_translation(sentence: str, target_word: str = "") -> dict | None:
s_hash = compute_sentence_hash(sentence, target_word)
with get_db() as conn:
row = conn.execute(
"""
SELECT original_text, translated_text, target_word, word_meaning, source, created_at
FROM sentence_translations
WHERE sentence_hash = ?
ORDER BY id DESC LIMIT 1
""",
(s_hash,),
).fetchone()
if row:
return dict(row)
return None
def save_sentence_translation(
sentence: str,
translated_text: str,
target_word: str = "",
word_meaning: str = "",
source: str = "local_llm",
) -> None:
s_hash = compute_sentence_hash(sentence, target_word)
with get_db() as conn:
conn.execute(
"""
INSERT INTO sentence_translations (sentence_hash, original_text, translated_text, target_word, word_meaning, source)
VALUES (?, ?, ?, ?, ?, ?)
""",
(s_hash, sentence.strip(), translated_text.strip(), target_word.strip(), word_meaning.strip(), source),
)
+36
View File
@@ -29,6 +29,8 @@ from app.dictionary import lookup_term, USER_AGENT, close_http_client
from app.export import export_csv, export_json, export_anki_tsv, export_markdown
from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level
from app.reader import parse_epub, parse_text, parse_pdf, count_words
from app.ai import translate_sentence_ai, get_ollama_status
@asynccontextmanager
@@ -153,6 +155,12 @@ class BookProgressRequest(BaseModel):
position: Optional[float] = None
class SentenceTranslateRequest(BaseModel):
sentence: str = Field(..., min_length=1)
target_word: Optional[str] = ""
# ---------------------------------------------------------------------------
# PWA Static Endpoints
# ---------------------------------------------------------------------------
@@ -321,6 +329,34 @@ async def api_audio_proxy():
return Response(status_code=204)
# ---------------------------------------------------------------------------
# Local AI Sentence Translation & In-Context Word Lookup
# ---------------------------------------------------------------------------
@app.post("/api/translate-sentence")
async def api_translate_sentence(
req: SentenceTranslateRequest,
user: dict = Depends(require_user)
):
"""
Translates a sentence using local AI with in-context meaning of the target word.
Uses SQLite sentence_translations cache for sub-millisecond retrieval on repeat queries.
"""
clean_s = req.sentence.strip()
if not clean_s:
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
return await translate_sentence_ai(clean_s, req.target_word or "")
@app.get("/api/ai/status")
async def api_ai_status(user: dict = Depends(require_user)):
"""
Returns local AI status and available models.
"""
return await get_ollama_status()
# ---------------------------------------------------------------------------
# Collections
# ---------------------------------------------------------------------------
+95 -1
View File
@@ -3858,6 +3858,10 @@ body {
margin-bottom: 8px;
}
.popup-context-box {
margin-bottom: 14px;
}
.popup-context {
font-size: 12px;
color: var(--text-muted);
@@ -3865,11 +3869,101 @@ body {
background: var(--bg-input);
padding: 8px 10px;
border-radius: var(--radius-sm);
margin-bottom: 14px;
margin-bottom: 6px;
line-height: 1.4;
border-left: 3px solid var(--primary);
}
.popup-sentence-ai-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.popup-sentence-btn {
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
gap: 4px;
}
.popup-ai-source-badge {
font-size: 10px;
font-weight: 600;
color: var(--primary);
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.25);
padding: 2px 6px;
border-radius: 9999px;
letter-spacing: 0.02em;
}
.popup-sentence-loading {
font-size: 11px;
color: var(--text-muted);
padding: 6px 8px;
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-input);
border-radius: var(--radius-sm);
margin-bottom: 6px;
}
.popup-sentence-translation-wrap {
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: var(--radius-sm);
padding: 8px 10px;
margin-bottom: 6px;
animation: fadeIn 0.15s ease-out;
}
.popup-sentence-ru {
font-size: 13px;
line-height: 1.45;
color: var(--text-main);
margin-bottom: 6px;
}
.popup-context-meaning-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
padding-top: 6px;
border-top: 1px dashed rgba(99, 102, 241, 0.2);
}
.context-meaning-label {
color: var(--text-muted);
}
.context-meaning-val {
color: var(--success);
font-weight: 600;
}
.use-meaning-btn {
font-size: 10px;
padding: 1px 6px;
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 4px;
cursor: pointer;
background: transparent;
}
.use-meaning-btn:hover {
background: rgba(99, 102, 241, 0.15);
}
.popup-saved-badge {
text-align: center;
font-size: 13px;
+19
View File
@@ -1062,7 +1062,26 @@
<div id="popup-loading" class="popup-loading hidden">Переводим...</div>
<div id="popup-content" class="popup-content">
<div class="popup-translation" id="popup-translation">перевод</div>
<div class="popup-context-box">
<div class="popup-context" id="popup-context">“Контекст из книги...”</div>
<div class="popup-sentence-ai-bar">
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
🌐 Перевести предложение
</button>
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
</div>
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
<span class="spinner-sm"></span> Локальный ИИ переводит...
</div>
<div id="popup-sentence-translation-wrap" class="popup-sentence-translation-wrap hidden">
<div class="popup-sentence-ru" id="popup-sentence-ru"></div>
<div id="popup-context-meaning-wrap" class="popup-context-meaning-wrap hidden">
<span class="context-meaning-label">🎯 В контексте:</span>
<strong id="popup-context-meaning-val" class="context-meaning-val"></strong>
<button type="button" id="popup-use-context-meaning-btn" class="btn btn-xs btn-ghost use-meaning-btn" title="Использовать этот перевод для карточки">Вставить в карточку</button>
</div>
</div>
</div>
</div>
</div>
<div class="popup-footer">
+30 -5
View File
@@ -66,11 +66,7 @@ export const api = {
return resp.ok ? await resp.json() : [];
},
async lookup(term) {
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
if (resp.ok) {
return await resp.json();
}
throw new Error(`Lookup failed: ${resp.status}`);
return await api.lookup.search(term);
},
async create(payload) {
return await fetch('/api/cards', {
@@ -98,6 +94,35 @@ export const api = {
}
},
// Dictionary Lookup & AI Sentence Translation
lookup: {
async search(term) {
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
if (resp.ok) {
return await resp.json();
}
throw new Error(`Lookup failed: ${resp.status}`);
},
async translateSentence(sentence, targetWord = '') {
const resp = await fetch('/api/translate-sentence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sentence: sentence,
target_word: targetWord
})
});
if (resp.ok) {
return await resp.json();
}
throw new Error(`Sentence translation failed: ${resp.status}`);
},
async getAiStatus() {
const resp = await fetch('/api/ai/status');
return resp.ok ? await resp.json() : { online: false, models: [] };
}
},
// Collections & Tags
collections: {
async getAll() {
+93
View File
@@ -1151,6 +1151,27 @@ export async function openWordPopup(term, contextSentence, targetElement) {
if (popupLoading) popupLoading.classList.remove('hidden');
if (popupContent) popupContent.classList.add('hidden');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar');
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = '';
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = '';
if (popupAiSourceBadge) popupAiSourceBadge.classList.add('hidden');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.disabled = false;
popupTranslateSentenceBtn.textContent = '🌐 Перевести предложение';
if (popupSentenceAiBar) popupSentenceAiBar.classList.toggle('hidden', !contextSentence);
}
if (popupInLibrary) {
popupInLibrary.classList.toggle('hidden', !existingCard);
}
@@ -1831,6 +1852,78 @@ export function initReader() {
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const ctxSentence = popupCurrentData.contextSentence;
const term = popupCurrentData.term;
popupTranslateSentenceBtn.disabled = true;
if (popupSentenceLoading) popupSentenceLoading.classList.remove('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
try {
const res = await api.lookup.translateSentence(ctxSentence, term);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) {
popupSentenceRu.textContent = res.sentence_ru || 'Перевод не получен';
}
if (res.word_meaning && res.word_meaning.trim()) {
popupCurrentData.contextMeaning = res.word_meaning.trim();
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.remove('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = res.word_meaning.trim();
} else {
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
}
if (popupAiSourceBadge) {
popupAiSourceBadge.classList.remove('hidden');
popupAiSourceBadge.textContent = res.cached ? '⚡ Кеш (0.01ms)' : '⚡ Local AI (Qwen)';
popupAiSourceBadge.title = res.source || '';
}
positionWordPopup(popupCurrentData.targetElement);
} catch (err) {
console.error('Sentence translation error:', err);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = 'Ошибка связи с локальным ИИ';
} finally {
popupTranslateSentenceBtn.disabled = false;
}
});
}
if (popupUseContextMeaningBtn) {
popupUseContextMeaningBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextMeaning) return;
const meaning = popupCurrentData.contextMeaning;
if (popupTranslation) popupTranslation.textContent = meaning;
if (popupCurrentData.lookup) {
popupCurrentData.lookup.translation = meaning;
}
popupUseContextMeaningBtn.textContent = '✓ Вставлено!';
setTimeout(() => {
if (popupUseContextMeaningBtn) popupUseContextMeaningBtn.textContent = 'Вставить в карточку';
}, 1800);
});
}
if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
+13
View File
@@ -15,4 +15,17 @@ services:
- SECRET_KEY=study-qpid-pro-secret-key-change-in-prod
- ADMIN_USERNAME=kopri
- DB_PATH=/app/data/notes.db
- OLLAMA_BASE_URL=http://ollama:11434
depends_on:
- ollama
ollama:
image: ollama/ollama:latest
container_name: study-ollama
restart: unless-stopped
ports:
- "127.0.0.1:11434:11434"
volumes:
- ./data/ollama:/root/.ollama
environment:
- OLLAMA_KEEP_ALIVE=5m
+66
View File
@@ -1206,3 +1206,69 @@ def test_offline_dictionary_lookup_and_coverage():
asyncio.run(run_lookups())
def test_sentence_translation_ai_and_caching():
"""Validates local AI sentence translation endpoint, SQLite caching, and in-context word meaning."""
import time
from app.database import compute_sentence_hash, get_cached_sentence_translation, save_sentence_translation
# 1. Unauthenticated request must be blocked
client.cookies.clear()
unauth_res = client.post("/api/translate-sentence", json={"sentence": "He had a prodigious memory."})
assert unauth_res.status_code == 401
# 2. Login as admin
login_res = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
assert login_res.status_code == 200
# 3. Empty sentence validation
empty_res = client.post("/api/translate-sentence", json={"sentence": " "})
assert empty_res.status_code == 400
# 4. Direct SQLite cache unit test
sentence = "He took the matter into his own hands and succeeded."
target_word = "matter"
h = compute_sentence_hash(sentence, target_word)
assert len(h) == 64
# Initially not cached
assert get_cached_sentence_translation(sentence, target_word) is None
# Save to cache
save_sentence_translation(
sentence=sentence,
translated_text="Он взял дело в свои руки и преуспел.",
target_word=target_word,
word_meaning="дело, вопрос",
source="unit_test",
)
cached_entry = get_cached_sentence_translation(sentence, target_word)
assert cached_entry is not None
assert cached_entry["translated_text"] == "Он взял дело в свои руки и преуспел."
assert cached_entry["word_meaning"] == "дело, вопрос"
assert cached_entry["source"] == "unit_test"
# 5. Fast cached retrieval via HTTP endpoint (< 10ms)
t0 = time.perf_counter()
api_res = client.post(
"/api/translate-sentence",
json={"sentence": sentence, "target_word": target_word},
)
dt_ms = (time.perf_counter() - t0) * 1000
assert api_res.status_code == 200
data = api_res.json()
assert data["cached"] is True
assert data["sentence_ru"] == "Он взял дело в свои руки и преуспел."
assert data["word_meaning"] == "дело, вопрос"
assert dt_ms < 50.0
# 6. Test AI status endpoint
status_res = client.get("/api/ai/status")
assert status_res.status_code == 200
status_data = status_res.json()
assert "online" in status_data
assert "models" in status_data
assert "active_model" in status_data