fix(ai,reader): robust sentence segmentation without abbreviation cutoffs & context-aware literary translation

- Implement robustSentenceSplit across reader.js and backend (miner.py, cefr.py) to prevent false cutoffs on honorifics (Mr., Mrs., Dr.), single-letter initials (John F. Kennedy), decimals, acronyms (U.S., U.K.), and abbreviations (No. 4, etc.)
- Upgrade translation prompt to professional literary Russian, eliminating literal word-for-word translation and preserving titles/honorifics
- Inject surrounding paragraph context into translation API to avoid out-of-context translations
- Inject offline dictionary hints into LLM prompt for accurate contextual word meaning
- Prefer qwen2.5:3b for higher translation fluency and fidelity
- Purge truncated cache entries from sentence_translations and restore Card 30 (briefcase) with authentic full sentence
- Bump Service Worker cache to notes-v6.0
- Add unit tests for robust sentence splitting, prompt formatting, and translation API
This commit is contained in:
Antigravity committed 2026-10-06 22:32:36 +03:00
1 parent 3098d3016d
commit 532e2ec3b2
11 files changed
+313 -89

No files matched your search

+106 -37
View File
@@ -1,46 +1,81 @@
import json
import logging
import re
from typing import Optional, Dict, Any
import httpx
from app import config
from app.database import get_cached_sentence_translation, save_sentence_translation
from app.database import get_db, get_cached_sentence_translation, save_sentence_translation
logger = logging.getLogger(__name__)
def build_translation_prompt(sentence: str, target_word: str = "") -> str:
def get_dictionary_hint(term: str) -> str:
"""Retrieves verified offline dictionary translation hints for term."""
clean = term.strip()
if not clean:
return ""
try:
with get_db() as conn:
row = conn.execute(
"SELECT translation FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(clean,),
).fetchone()
if row and row["translation"]:
return row["translation"].strip()
except Exception:
pass
return ""
def build_translation_prompt(
sentence: str,
target_word: str = "",
context: str = "",
dict_hint: str = "",
) -> str:
clean_sentence = sentence.strip()
clean_word = target_word.strip()
clean_context = context.strip()
context_section = ""
if clean_context and clean_context != clean_sentence:
short_ctx = clean_context[:500].strip()
context_section = f'Контекст абзаца:\n"{short_ctx}"\n\n'
target_section = ""
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'
"}"
)
target_section = f'Целевое слово/выражение: "{clean_word}"\n'
if dict_hint:
target_section += f'Возможные словарные значения: {dict_hint}\n'
target_section += "\n"
return (
"Ты — профессиональный литературный переводчик художественной и научно-популярной литературы на русский язык.\n"
"Твоя задача — перевести указанное английское предложение на красивый, естественный и грамотный русский литературный язык, идеально вписывающийся в повествование книги.\n\n"
f"{context_section}"
f'Предложение для перевода:\n"{clean_sentence}"\n\n'
f"{target_section}"
"Обязательные правила перевода:\n"
"1. Перевод должен быть живым и художественным (НЕ дословным, без топорного калькирования).\n"
"2. Переводи всё предложение ПОЛНОСТЬЮ от начала до конца, не обрывая на точках, обращениях или знаках препинания.\n"
"3. Обязательно сохраняй обращения и титулы: Mr. -> мистер, Mrs. -> миссис, Dr. -> доктор, Prof. -> профессор, St. -> святой/Сент-. Не теряй их!\n"
"4. В поле \"word_meaning\" укажи только точный перевод выделенного слова на русский язык в контексте данного предложения (выбрав самый подходящий вариант из словарных подсказок).\n\n"
"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON:\n"
"{\n"
' "sentence_ru": "полный литературный перевод всего предложения",\n'
f' "word_meaning": "{"русский перевод слова в данном контексте" if clean_word else ""}"\n'
"}"
)
async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[str, Any]:
async def translate_sentence_ai(
sentence: str,
target_word: str = "",
context: str = ""
) -> Dict[str, Any]:
"""
Translates a sentence using local AI (Ollama), with SQLite instant-cache lookup.
Incorporates paragraph context and offline dictionary hints for maximum naturalness.
Returns:
{
"original_sentence": "...",
@@ -53,6 +88,7 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
"""
clean_sentence = sentence.strip()
clean_word = target_word.strip()
clean_context = context.strip()
if not clean_sentence:
return {
@@ -76,8 +112,17 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
"cached": True,
}
# 2. Local AI Generation (Ollama)
prompt = build_translation_prompt(clean_sentence, clean_word)
# 2. Retrieve offline dictionary hint to steer LLM towards authentic meanings
dict_hint = get_dictionary_hint(clean_word) if clean_word else ""
# 3. Local AI Generation (Ollama)
prompt = build_translation_prompt(
sentence=clean_sentence,
target_word=clean_word,
context=clean_context,
dict_hint=dict_hint,
)
payload = {
"model": config.OLLAMA_MODEL,
"prompt": prompt,
@@ -85,7 +130,7 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
"format": "json",
"options": {
"temperature": 0.2,
"num_predict": 256,
"num_predict": 300,
},
}
@@ -94,17 +139,15 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
source = f"ollama:{config.OLLAMA_MODEL}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(timeout=35.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
# In case LLM emitted markdown wrapper ```json ... ```
clean_raw = raw_response.strip()
if clean_raw.startswith("```json"):
clean_raw = clean_raw[7:]
@@ -112,15 +155,41 @@ async def translate_sentence_ai(sentence: str, target_word: str = "") -> Dict[st
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()
try:
parsed = json.loads(clean_raw.strip())
except Exception:
parsed = {}
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()
# 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 = ""
word_meaning = raw_meaning
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
# 4. Graceful Fallback if Ollama is unreachable
if not sentence_ru:
sentence_ru = "(Локальный ИИ временно недоступен)"
source = "fallback"