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:
1 parent
3098d3016d
commit
532e2ec3b2
11 files changed
+313
-89
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user