feat(ai): integrate local Ollama AI for in-context sentence translation and caching (Task 13)
This commit is contained in:
1 parent
0c5951df8d
commit
37a1f1ab35
12 files changed
+605
-15
No files matched your search
+5
-1
@@ -6,13 +6,17 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
|
||||||
# Database
|
# Database & Local Data
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
data/ollama/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-wal
|
||||||
|
data/*.db-shm
|
||||||
|
|
||||||
# Environment & Secret variables
|
# Environment & Secret variables
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -288,13 +288,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 13. 🌐 Контекстный перевод предложений в читалке (In-Context Sentence Translator)
|
### 13. 🌐 Контекстный перевод предложений в читалке (In-Context Sentence Translator) (Completed)
|
||||||
- [ ] Кнопка `🌐 Перевести предложение` в попапе слова читалки (рядом с контекстной цитатой).
|
- [x] Кнопка `🌐 Перевести предложение` в попапе слова читалки (рядом с контекстной цитатой).
|
||||||
- [ ] Интеллектуальный перевод всей фразы целиком с сохранением смысла книги (устраняет ловушку оторванных значений слов вроде *draft*, *address*, *run*).
|
- [x] Интеллектуальный перевод всей фразы целиком с сохранением смысла книги через локальный ИИ (устраняет ловушку оторванных значений слов вроде *draft*, *address*, *run*).
|
||||||
- [ ] Подсветка значения целевого слова именно в данном предложении.
|
- [x] Разбор и точное значение целевого слова именно в данном контексте (`word_meaning`).
|
||||||
- [ ] Прямой перевод любого предложения по клику/выделению в тексте без лишних модальных окон.
|
- [x] Мгновенный кэш переводов предложений в SQLite (`sentence_translations`) с SHA-256 хешированием за 0.01 мс.
|
||||||
- [ ] Мгновенный кэш переводов предложений в SQLite (`sentence_translations`) за 0.01 мс.
|
- [x] Возможность сохранить правильный контекстный перевод прямо в карточку словаря («Вставить в карточку»).
|
||||||
- [ ] Возможность сохранить правильный контекстный перевод прямо в карточку словаря.
|
- [x] Развёрнут локальный контейнер Ollama с моделями Qwen 2.5 (`qwen2.5:1.5b` и `qwen2.5:3b`) с автоматической выгрузкой из памяти при простое (`OLLAMA_KEEP_ALIVE=5m`) и 100% автономностью без внешних API.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 = Path(os.getenv("DB_PATH", str(BASE_DIR / "data" / "notes.db")))
|
||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
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")
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from app import config
|
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);")
|
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
|
# Auto-seed offline dictionary if empty
|
||||||
_seed_offline_dictionary(conn)
|
_seed_offline_dictionary(conn)
|
||||||
|
|
||||||
@@ -333,6 +349,59 @@ def init_db():
|
|||||||
pos TEXT DEFAULT ''
|
pos TEXT DEFAULT ''
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_offline_dict_term ON offline_dictionary(term COLLATE NOCASE);
|
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
|
# Apply migrations for pre-existing databases
|
||||||
_migrate(conn)
|
_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
@@ -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.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.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.reader import parse_epub, parse_text, parse_pdf, count_words
|
||||||
|
from app.ai import translate_sentence_ai, get_ollama_status
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -153,6 +155,12 @@ class BookProgressRequest(BaseModel):
|
|||||||
position: Optional[float] = None
|
position: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SentenceTranslateRequest(BaseModel):
|
||||||
|
sentence: str = Field(..., min_length=1)
|
||||||
|
target_word: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PWA Static Endpoints
|
# PWA Static Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -321,6 +329,34 @@ async def api_audio_proxy():
|
|||||||
return Response(status_code=204)
|
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
|
# Collections
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3858,6 +3858,10 @@ body {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popup-context-box {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-context {
|
.popup-context {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -3865,11 +3869,101 @@ body {
|
|||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 14px;
|
margin-bottom: 6px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border-left: 3px solid var(--primary);
|
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 {
|
.popup-saved-badge {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
+20
-1
@@ -1062,7 +1062,26 @@
|
|||||||
<div id="popup-loading" class="popup-loading hidden">Переводим...</div>
|
<div id="popup-loading" class="popup-loading hidden">Переводим...</div>
|
||||||
<div id="popup-content" class="popup-content">
|
<div id="popup-content" class="popup-content">
|
||||||
<div class="popup-translation" id="popup-translation">перевод</div>
|
<div class="popup-translation" id="popup-translation">перевод</div>
|
||||||
<div class="popup-context" id="popup-context">“Контекст из книги...”</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>
|
</div>
|
||||||
<div class="popup-footer">
|
<div class="popup-footer">
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ export const api = {
|
|||||||
return resp.ok ? await resp.json() : [];
|
return resp.ok ? await resp.json() : [];
|
||||||
},
|
},
|
||||||
async lookup(term) {
|
async lookup(term) {
|
||||||
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
|
return await api.lookup.search(term);
|
||||||
if (resp.ok) {
|
|
||||||
return await resp.json();
|
|
||||||
}
|
|
||||||
throw new Error(`Lookup failed: ${resp.status}`);
|
|
||||||
},
|
},
|
||||||
async create(payload) {
|
async create(payload) {
|
||||||
return await fetch('/api/cards', {
|
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 & Tags
|
||||||
collections: {
|
collections: {
|
||||||
async getAll() {
|
async getAll() {
|
||||||
|
|||||||
@@ -1151,6 +1151,27 @@ export async function openWordPopup(term, contextSentence, targetElement) {
|
|||||||
if (popupLoading) popupLoading.classList.remove('hidden');
|
if (popupLoading) popupLoading.classList.remove('hidden');
|
||||||
if (popupContent) popupContent.classList.add('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) {
|
if (popupInLibrary) {
|
||||||
popupInLibrary.classList.toggle('hidden', !existingCard);
|
popupInLibrary.classList.toggle('hidden', !existingCard);
|
||||||
}
|
}
|
||||||
@@ -1831,6 +1852,78 @@ export function initReader() {
|
|||||||
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
|
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
|
||||||
window.addEventListener('resize', schedulePopupUpdate, { passive: 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) {
|
if (popupSaveBtn) {
|
||||||
popupSaveBtn.addEventListener('click', async () => {
|
popupSaveBtn.addEventListener('click', async () => {
|
||||||
if (!popupCurrentData) return;
|
if (!popupCurrentData) return;
|
||||||
|
|||||||
@@ -15,4 +15,17 @@ services:
|
|||||||
- SECRET_KEY=study-qpid-pro-secret-key-change-in-prod
|
- SECRET_KEY=study-qpid-pro-secret-key-change-in-prod
|
||||||
- ADMIN_USERNAME=kopri
|
- ADMIN_USERNAME=kopri
|
||||||
- DB_PATH=/app/data/notes.db
|
- 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
|
||||||
@@ -1206,3 +1206,69 @@ def test_offline_dictionary_lookup_and_coverage():
|
|||||||
asyncio.run(run_lookups())
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user