fix(reader): prevent false phrasal verb detection for noun collocations and improve literary translation accuracy
This commit is contained in:
@@ -9,6 +9,36 @@ from app.database import get_db, get_cached_sentence_translation, save_sentence_
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
COMMON_STOPWORDS = {
|
||||||
|
"a", "an", "the", "in", "on", "at", "to", "for", "of", "with", "by", "from",
|
||||||
|
"up", "about", "into", "over", "after", "beneath", "under", "above", "is", "am",
|
||||||
|
"are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does",
|
||||||
|
"did", "and", "but", "if", "or", "because", "as", "until", "while", "this", "that",
|
||||||
|
"these", "those", "i", "you", "he", "she", "it", "we", "they", "them", "him", "her",
|
||||||
|
"us", "me", "my", "your", "his", "its", "our", "their", "not", "no", "nor", "can",
|
||||||
|
"could", "will", "would", "shall", "should", "may", "might", "must", "there", "here",
|
||||||
|
"when", "where", "why", "how", "all", "any", "both", "each", "few", "more", "most",
|
||||||
|
"other", "some", "such", "than", "too", "very", "so", "s", "t", "d", "ll", "ve", "m"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_dict_translation(raw: str) -> str:
|
||||||
|
"""Extracts first 1-2 clean Cyrillic meanings, stripping phonetic / Latin noise."""
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
clean = re.sub(r'/[^/]*$', '', raw)
|
||||||
|
parts = re.split(r'[,;]\s*', clean)
|
||||||
|
cyrillic_parts = []
|
||||||
|
for p in parts:
|
||||||
|
p_clean = re.sub(r"[a-zA-Záéíóúýšžčñ']+", '', p).strip()
|
||||||
|
p_clean = re.sub(r'\s+', ' ', p_clean).strip(' /\\,;-()[]')
|
||||||
|
if p_clean and re.search(r'[\u0400-\u04FF]', p_clean) and p_clean not in cyrillic_parts:
|
||||||
|
cyrillic_parts.append(p_clean)
|
||||||
|
if len(cyrillic_parts) >= 2:
|
||||||
|
break
|
||||||
|
return ", ".join(cyrillic_parts) if cyrillic_parts else raw[:50].strip()
|
||||||
|
|
||||||
|
|
||||||
def get_dictionary_hint(term: str) -> str:
|
def get_dictionary_hint(term: str) -> str:
|
||||||
"""Retrieves verified offline dictionary translation hints for term."""
|
"""Retrieves verified offline dictionary translation hints for term."""
|
||||||
clean = term.strip()
|
clean = term.strip()
|
||||||
@@ -21,17 +51,80 @@ def get_dictionary_hint(term: str) -> str:
|
|||||||
(clean,),
|
(clean,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row and row["translation"]:
|
if row and row["translation"]:
|
||||||
return row["translation"].strip()
|
return clean_dict_translation(row["translation"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str:
|
||||||
|
"""Extracts verified offline dictionary translations for key words and phrases in sentence."""
|
||||||
|
if not sentence:
|
||||||
|
return ""
|
||||||
|
words = re.findall(r"\b[a-zA-Z\'-]+\b", sentence)
|
||||||
|
if not words:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
candidates = set()
|
||||||
|
# Check 2-word contiguous phrases (collocations/idioms)
|
||||||
|
for i in range(len(words) - 1):
|
||||||
|
phrase = f"{words[i]} {words[i+1]}".lower()
|
||||||
|
candidates.add(phrase)
|
||||||
|
|
||||||
|
# Check content words
|
||||||
|
for w in words:
|
||||||
|
wl = w.lower()
|
||||||
|
if len(wl) > 2 and wl not in COMMON_STOPWORDS:
|
||||||
|
candidates.add(wl)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
hints = []
|
||||||
|
special_guidance = {
|
||||||
|
"owl": "owl — это сова/неясыть (ни в коем случае НЕ орёл!)",
|
||||||
|
"tawny owl": "tawny owl — это сова/неясыть (птица семейства совиных, ни в коем случае НЕ орёл!)",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
placeholders = ",".join(["?"] * len(candidates))
|
||||||
|
query = f"SELECT term, translation FROM offline_dictionary WHERE term IN ({placeholders}) COLLATE NOCASE"
|
||||||
|
rows = conn.execute(query, list(candidates)).fetchall()
|
||||||
|
|
||||||
|
matched_dict = {}
|
||||||
|
for r in rows:
|
||||||
|
term_lower = r["term"].lower()
|
||||||
|
clean_trans = clean_dict_translation(r["translation"])
|
||||||
|
if clean_trans:
|
||||||
|
matched_dict[term_lower] = clean_trans
|
||||||
|
|
||||||
|
seen_terms = set()
|
||||||
|
for cand in sorted(matched_dict.keys(), key=lambda x: (0 if " " in x else 1, 0 if x == target_word.lower() else 1)):
|
||||||
|
if cand in seen_terms:
|
||||||
|
continue
|
||||||
|
seen_terms.add(cand)
|
||||||
|
trans = matched_dict[cand]
|
||||||
|
guidance = special_guidance.get(cand, "")
|
||||||
|
if guidance:
|
||||||
|
hints.append(f"- {cand}: {trans} (ВНИМАНИЕ: {guidance})")
|
||||||
|
else:
|
||||||
|
hints.append(f"- {cand}: {trans}")
|
||||||
|
|
||||||
|
if len(hints) >= 8:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error extracting sentence dictionary hints: {e}")
|
||||||
|
|
||||||
|
return "\n".join(hints)
|
||||||
|
|
||||||
|
|
||||||
def build_translation_prompt(
|
def build_translation_prompt(
|
||||||
sentence: str,
|
sentence: str,
|
||||||
target_word: str = "",
|
target_word: str = "",
|
||||||
context: str = "",
|
context: str = "",
|
||||||
dict_hint: str = "",
|
dict_hint: str = "",
|
||||||
|
sentence_hints: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
clean_sentence = sentence.strip()
|
clean_sentence = sentence.strip()
|
||||||
clean_word = target_word.strip()
|
clean_word = target_word.strip()
|
||||||
@@ -49,18 +142,24 @@ def build_translation_prompt(
|
|||||||
target_section += f'Возможные словарные значения: {dict_hint}\n'
|
target_section += f'Возможные словарные значения: {dict_hint}\n'
|
||||||
target_section += "\n"
|
target_section += "\n"
|
||||||
|
|
||||||
|
hints_section = ""
|
||||||
|
if sentence_hints:
|
||||||
|
hints_section = f"Словарные подсказки по ключевым словам предложения:\n{sentence_hints}\n\n"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"Ты — профессиональный литературный переводчик художественной и научно-популярной литературы на русский язык.\n"
|
"Ты — профессиональный литературный переводчик художественной и научно-популярной литературы на русский язык.\n"
|
||||||
"Твоя задача — перевести указанное английское предложение на красивый, естественный, богатый и грамматически безупречный русский язык, идеально вписывающийся в повествование книги.\n\n"
|
"Твоя задача — перевести указанное английское предложение на красивый, естественный, богатый и грамматически безупречный русский язык, идеально вписывающийся в повествование книги.\n\n"
|
||||||
f"{context_section}"
|
f"{context_section}"
|
||||||
f'Предложение для перевода:\n"{clean_sentence}"\n\n'
|
f'Предложение для перевода:\n"{clean_sentence}"\n\n'
|
||||||
f"{target_section}"
|
f"{target_section}"
|
||||||
|
f"{hints_section}"
|
||||||
"Обязательные правила перевода:\n"
|
"Обязательные правила перевода:\n"
|
||||||
"1. ПОЛНОТА И СВЯЗНОСТЬ: Переводи абсолютно ВСЕ смысловые части и придаточные обороты без пропусков! Обращай особое внимание на бессоюзные определительные обороты (zero relative clauses, например: 'the book I read' -> 'книга, которую я читал', 'Tuesday our story starts' -> 'вторник, с которого начинается наша история'). Ничего не выбрасывай!\n"
|
"1. ПОЛНОТА И СВЯЗНОСТЬ: Переводи абсолютно ВСЕ смысловые части, обороты и определения без пропусков! Обращай особое внимание на цепочки прилагательных и определений (например: 'a large, tawny owl' -> 'крупная неясыть' или 'большая рыжевато-бурая сова' — ни в коем случае не пропускай 'large'!). Также переводи бессоюзные определительные обороты (zero relative clauses, например: 'Tuesday our story starts' -> 'вторник, с которого начинается наша история'). Ничего не выбрасывай!\n"
|
||||||
"2. БЕЗ ДОСЛОВНОГО КАЛЬКИРОВАНИЯ: Никаких топорных калек предлогов ('on Tuesday' -> 'во вторник', а не 'на вторнике'). Парные эпитеты ('dull, gray') переводи богатыми русскими синонимами ('пасмурный, серый'), не повторяя одно и то же слово.\n"
|
"2. БЕЗ ГАЛЛЮЦИНАЦИЙ И ТОЧНОСТЬ ЗНАЧЕНИЙ: Фауна, флора и объекты должны переводиться строго точно по смыслу и словарю (например: 'owl' — это сова/неясыть, ни в коем случае НЕ орёл!). Никаких смысловых подмен.\n"
|
||||||
"3. ИМЕНА И ТИТУЛЫ: Имена персонажей и фамилии передавай полностью на русском языке (кириллицей), без латинских букв (Mr. and Mrs. Dursley -> мистер и миссис Дурсли). Сохраняй титулы: Mr. -> мистер, Mrs. -> миссис, Dr. -> доктор, Prof. -> профессор.\n"
|
"3. БЕЗ ДОСЛОВНОГО КАЛЬКИРОВАНИЯ: Никаких топорных калек предлогов ('on Tuesday' -> 'во вторник', а не 'на вторнике'). Парные эпитеты ('dull, gray') переводи богатыми русскими синонимами ('пасмурный, серый'), не повторяя одно и то же слово.\n"
|
||||||
"4. РУССКИЙ СИНТАКСИС: Сохраняй естественный русский порядок слов (обстоятельства времени и места ставь перед сказуемым: 'вскоре по всей стране начнут происходить...').\n"
|
"4. ИМЕНА И ТИТУЛЫ: Имена персонажей и фамилии передавай полностью на русском языке (кириллицей), без латинских букв (Mr. and Mrs. Dursley -> мистер и миссис Дурсли). Сохраняй титулы: Mr. -> мистер, Mrs. -> миссис, Dr. -> доктор, Prof. -> профессор.\n"
|
||||||
f'5. В поле "word_meaning" укажи только точный перевод слова "{clean_word}" на русский язык в контексте этого предложения (выбрав лучший вариант из подсказок).\n\n'
|
"5. РУССКИЙ СИНТАКСИС: Сохраняй естественный русский порядок слов (обстоятельства времени и места ставь перед сказуемым: 'вскоре по всей стране начнут происходить...').\n"
|
||||||
|
f'6. В поле "word_meaning" укажи только точный перевод слова "{clean_word}" на русский язык в контексте этого предложения (выбрав лучший вариант из подсказок).\n\n'
|
||||||
"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON:\n"
|
"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON:\n"
|
||||||
"{\n"
|
"{\n"
|
||||||
' "sentence_ru": "полный качественный литературный перевод всего предложения без пропусков",\n'
|
' "sentence_ru": "полный качественный литературный перевод всего предложения без пропусков",\n'
|
||||||
@@ -113,8 +212,9 @@ async def translate_sentence_ai(
|
|||||||
"cached": True,
|
"cached": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. Retrieve offline dictionary hint to steer LLM towards authentic meanings
|
# 2. Retrieve offline dictionary hints to steer LLM towards authentic meanings
|
||||||
dict_hint = get_dictionary_hint(clean_word) if clean_word else ""
|
dict_hint = get_dictionary_hint(clean_word) if clean_word else ""
|
||||||
|
sentence_hints = get_sentence_dictionary_hints(clean_sentence, clean_word)
|
||||||
|
|
||||||
# 3. Local AI Generation (Ollama)
|
# 3. Local AI Generation (Ollama)
|
||||||
prompt = build_translation_prompt(
|
prompt = build_translation_prompt(
|
||||||
@@ -122,6 +222,7 @@ async def translate_sentence_ai(
|
|||||||
target_word=clean_word,
|
target_word=clean_word,
|
||||||
context=clean_context,
|
context=clean_context,
|
||||||
dict_hint=dict_hint,
|
dict_hint=dict_hint,
|
||||||
|
sentence_hints=sentence_hints,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
+22
-5
@@ -46,7 +46,14 @@ IRREGULARS = {
|
|||||||
|
|
||||||
PRONOUNS_AND_OBJECTS = {
|
PRONOUNS_AND_OBJECTS = {
|
||||||
"it", "him", "her", "them", "us", "me", "you", "this", "that", "these", "those",
|
"it", "him", "her", "them", "us", "me", "you", "this", "that", "these", "those",
|
||||||
"things", "something", "everything", "anything", "all", "back", "away"
|
"things", "something", "everything", "anything", "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
PHRASAL_PARTICLES = {
|
||||||
|
"about", "across", "ahead", "along", "apart", "around", "round", "aside",
|
||||||
|
"at", "away", "back", "by", "down", "for", "forth", "forward", "in",
|
||||||
|
"into", "off", "on", "onto", "out", "over", "past", "round", "through",
|
||||||
|
"to", "together", "under", "up", "upon", "with"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Curated high-frequency English phrasal verbs (CEFR A2-B2) with clean translations
|
# Curated high-frequency English phrasal verbs (CEFR A2-B2) with clean translations
|
||||||
@@ -326,12 +333,18 @@ def detect_phrasal_verb(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check offline dictionary
|
# Check offline dictionary only for genuine phrasal verbs:
|
||||||
|
# 1. Candidate must end with a known particle (e.g. 'turn on', 'come up with')
|
||||||
|
# 2. pos in offline_dictionary must explicitly be a verb / verb phrase
|
||||||
|
cand_words = cand.split()
|
||||||
|
if len(cand_words) >= 2 and cand_words[-1] in PHRASAL_PARTICLES:
|
||||||
row = execute_query(
|
row = execute_query(
|
||||||
"SELECT term, translation FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
"SELECT term, translation, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
(cand,),
|
(cand,),
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
|
pos_val = (row[2] or "").lower()
|
||||||
|
if "verb" in pos_val:
|
||||||
match_result = {
|
match_result = {
|
||||||
"term": row[0],
|
"term": row[0],
|
||||||
"raw": raw_match,
|
"raw": raw_match,
|
||||||
@@ -352,8 +365,10 @@ def detect_phrasal_verb(
|
|||||||
if lower_tokens[v_idx + 1] in PRONOUNS_AND_OBJECTS:
|
if lower_tokens[v_idx + 1] in PRONOUNS_AND_OBJECTS:
|
||||||
p_idx = v_idx + 2
|
p_idx = v_idx + 2
|
||||||
if idx in (v_idx, p_idx):
|
if idx in (v_idx, p_idx):
|
||||||
v_lemmas = lemmatize_word(lower_tokens[v_idx])
|
|
||||||
particle = lower_tokens[p_idx]
|
particle = lower_tokens[p_idx]
|
||||||
|
if particle not in PHRASAL_PARTICLES:
|
||||||
|
continue
|
||||||
|
v_lemmas = lemmatize_word(lower_tokens[v_idx])
|
||||||
raw_match = " ".join(tokens[v_idx : p_idx + 1])
|
raw_match = " ".join(tokens[v_idx : p_idx + 1])
|
||||||
for v in v_lemmas:
|
for v in v_lemmas:
|
||||||
cand = f"{v} {particle}"
|
cand = f"{v} {particle}"
|
||||||
@@ -367,10 +382,12 @@ def detect_phrasal_verb(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
row = execute_query(
|
row = execute_query(
|
||||||
"SELECT term, translation FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
"SELECT term, translation, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
(cand,),
|
(cand,),
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
|
pos_val = (row[2] or "").lower()
|
||||||
|
if "verb" in pos_val:
|
||||||
match_result = {
|
match_result = {
|
||||||
"term": row[0],
|
"term": row[0],
|
||||||
"raw": raw_match,
|
"raw": raw_match,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Service Worker for Notes English PWA (Stage 2)
|
// Service Worker for Notes English PWA (Stage 2)
|
||||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||||
|
|
||||||
const CACHE_NAME = 'notes-v6.0';
|
const CACHE_NAME = 'notes-v6.1';
|
||||||
const OFFLINE_URL = '/';
|
const OFFLINE_URL = '/';
|
||||||
|
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
|
|||||||
@@ -1955,6 +1955,79 @@ def test_robust_sentence_split_and_ai_translation(monkeypatch):
|
|||||||
assert tr_data["target_word"] == "briefcase"
|
assert tr_data["target_word"] == "briefcase"
|
||||||
|
|
||||||
|
|
||||||
|
def test_phrasal_verb_detection_no_false_positive_for_noun_phrase():
|
||||||
|
from app.phrasal import detect_phrasal_verb
|
||||||
|
|
||||||
|
# Seed temporary test dictionary with tawny owl
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, '', ?)",
|
||||||
|
("tawny owl", "неясыть, неясыть серая", "Noun"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, '', ?)",
|
||||||
|
("owl", "сыч, сова, филин", "Noun"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, '', ?)",
|
||||||
|
("tawny", "рыжевато-коричневый", "Adjective"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sentence = "None of them noticed a large, tawny owl flutter past the window."
|
||||||
|
# tawny owl is a noun phrase (an animal species), NOT a phrasal verb!
|
||||||
|
assert detect_phrasal_verb(sentence, "owl") is None
|
||||||
|
assert detect_phrasal_verb(sentence, "tawny") is None
|
||||||
|
assert detect_phrasal_verb(sentence, "large") is None
|
||||||
|
|
||||||
|
# Genuine phrasal verbs must still be detected
|
||||||
|
s2 = "None of them noticed him take off his coat."
|
||||||
|
pv2 = detect_phrasal_verb(s2, "take")
|
||||||
|
assert pv2 is not None
|
||||||
|
assert pv2["term"] == "take off"
|
||||||
|
|
||||||
|
s3 = "He turned it off quietly."
|
||||||
|
pv3 = detect_phrasal_verb(s3, "turned")
|
||||||
|
assert pv3 is not None
|
||||||
|
assert pv3["term"] == "turn off"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sentence_dictionary_hints_and_fauna_precision():
|
||||||
|
from app.ai import get_sentence_dictionary_hints, build_translation_prompt
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, '', ?)",
|
||||||
|
("tawny owl", "неясыть, неясыть серая", "Noun"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, '', ?)",
|
||||||
|
("owl", "сыч, сова, филин", "Noun"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, '', ?)",
|
||||||
|
("large", "большой, крупный", "Adjective"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sentence = "None of them noticed a large, tawny owl flutter past the window."
|
||||||
|
hints = get_sentence_dictionary_hints(sentence, "tawny")
|
||||||
|
assert "owl" in hints
|
||||||
|
assert "НЕ орёл" in hints
|
||||||
|
assert "large" in hints
|
||||||
|
|
||||||
|
prompt = build_translation_prompt(
|
||||||
|
sentence=sentence,
|
||||||
|
target_word="tawny",
|
||||||
|
context="",
|
||||||
|
dict_hint="рыжевато-коричневый",
|
||||||
|
sentence_hints=hints,
|
||||||
|
)
|
||||||
|
assert "Словарные подсказки по ключевым словам предложения" in prompt
|
||||||
|
assert "НЕ орёл" in prompt
|
||||||
|
assert "ПОЛНОТА И СВЯЗНОСТЬ" in prompt
|
||||||
|
assert "large" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user