feat(reader): multi-word and phrasal verb detector in reader (Task 14)

This commit is contained in:
Antigravity committed 2026-10-06 15:16:02 +03:00
1 parent 37a1f1ab35
commit 1c3f03a64c
9 files changed
+738 -16

No files matched your search

+6 -5
View File
@@ -298,11 +298,12 @@
--- ---
### 14. 🔗 Детектор фразовых глаголов и идиом в читалке (Multi-Word / Phrasal Verb Detector) ### 14. 🔗 Детектор фразовых глаголов и идиом в читалке (Multi-Word / Phrasal Verb Detector) (Completed)
- [ ] База частых английских фразовых глаголов и коллокаций (A2–B2: *take off, run out of, look forward to, figure out, set up, carry out* и др.). - [x] База частых английских фразовых глаголов и коллокаций (A2–B2: *take off, run out of, look forward to, figure out, set up, carry out, turn off, put up with* и др.).
- [ ] При клике на слово в читалке — автоматический анализ контекстного окна (1–2 слова до и после). - [x] При клике на слово в читалке — автоматический анализ контекстного окна (1–3 слова до и после + разделяемые фразовые глаголы *turn it off*).
- [ ] При обнаружении устойчивой связки — выделение и перевод всей конструкции целиком вместо оторванного значения отдельного глагола (*took off* $\rightarrow$ «взлетел», а не «взял»). - [x] При обнаружении устойчивой связки — карточка связки прямо в окне перевода слова (*took off* $\rightarrow$ «взлетать, снимать», а не «взял»).
- [ ] Сохранение готовой устойчивой связки в личный словарь в один клик. - [x] Поддержка переключения между одиночным словом и фразовым глаголом в 1 клик.
- [x] Сохранение готовой устойчивой связки в личный словарь в один клик (в отдельную коллекцию «Фразовые глаголы & Идиомы» с тегом `#phrasal-verbs`).
--- ---
+7
View File
@@ -205,6 +205,13 @@ def _migrate(conn: sqlite3.Connection):
# Auto-seed offline dictionary if empty # Auto-seed offline dictionary if empty
_seed_offline_dictionary(conn) _seed_offline_dictionary(conn)
# Seed curated phrasal verbs
try:
from app.phrasal import seed_curated_phrasal_verbs
seed_curated_phrasal_verbs(conn)
except Exception:
pass
def _seed_offline_dictionary(conn: sqlite3.Connection): def _seed_offline_dictionary(conn: sqlite3.Connection):
"""Seeds the offline dictionary from data/offline_dict.tsv.gz if the table is empty.""" """Seeds the offline dictionary from data/offline_dict.tsv.gz if the table is empty."""
+30
View File
@@ -30,6 +30,8 @@ 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 from app.ai import translate_sentence_ai, get_ollama_status
from app.phrasal import detect_phrasal_verb
@@ -299,6 +301,7 @@ async def api_change_password(req: ChangePasswordRequest, user: dict = Depends(r
async def api_lookup( async def api_lookup(
term: Optional[str] = None, term: Optional[str] = None,
q: Optional[str] = None, q: Optional[str] = None,
context: Optional[str] = None,
user: dict = Depends(require_user) user: dict = Depends(require_user)
): ):
query_str = (term or q or "").strip() query_str = (term or q or "").strip()
@@ -319,9 +322,36 @@ async def api_lookup(
data["in_library"] = False data["in_library"] = False
data["library_card"] = None data["library_card"] = None
# Multi-Word & Phrasal Verb Detection
data["phrasal_verb"] = None
if context:
pv = detect_phrasal_verb(
sentence=context,
target_word=query_str,
user_id=user["id"],
conn=conn,
)
if pv and pv["term"].lower() != query_str.lower():
data["phrasal_verb"] = pv
return data return data
@app.get("/api/detect-phrasal-verb")
async def api_detect_phrasal_verb(
sentence: str = Query(..., min_length=1),
word: str = Query(..., min_length=1),
user: dict = Depends(require_user)
):
"""
Analyzes sentence context around word to detect phrasal verbs, idioms, and collocations.
"""
with get_db() as conn:
pv = detect_phrasal_verb(sentence=sentence, target_word=word, user_id=user["id"], conn=conn)
return {"detected": bool(pv), "phrasal_verb": pv}
@app.api_route("/api/audio", methods=["GET", "HEAD"]) @app.api_route("/api/audio", methods=["GET", "HEAD"])
async def api_audio_proxy(): async def api_audio_proxy():
+419
View File
@@ -0,0 +1,419 @@
"""
Phrasal Verb & Multi-Word Collocation Detector.
Analyzes context sentence tokens around a clicked word to identify
phrasal verbs (e.g. 'take off', 'look forward to', 'turn it off') and idioms.
100% offline, zero-dependency, pure Python stdlib + SQLite.
"""
import re
import sqlite3
from typing import Optional, Dict, Any, List
from app.database import get_db
IRREGULARS = {
"took": "take", "taken": "take", "takes": "take", "taking": "take",
"gave": "give", "given": "give", "gives": "give", "giving": "give",
"got": "get", "gotten": "get", "gets": "get", "getting": "get",
"went": "go", "gone": "go", "goes": "go", "going": "go",
"came": "come", "comes": "come", "coming": "come",
"made": "make", "makes": "make", "making": "make",
"put": "put", "puts": "put", "putting": "put",
"ran": "run", "runs": "run", "running": "run",
"held": "hold", "holds": "hold", "holding": "hold",
"brought": "bring", "brings": "bring", "bringing": "bring",
"broke": "break", "broken": "break", "breaks": "break", "breaking": "break",
"fell": "fall", "fallen": "fall", "falls": "fall", "falling": "fall",
"kept": "keep", "keeps": "keep", "keeping": "keep",
"stood": "stand", "stands": "stand", "standing": "stand",
"drew": "draw", "drawn": "draw", "draws": "draw", "drawing": "draw",
"found": "find", "finds": "find", "finding": "find",
"blew": "blow", "blown": "blow", "blows": "blow", "blowing": "blow",
"hung": "hang", "hangs": "hang", "hanging": "hang",
"caught": "catch", "catches": "catch", "catching": "catch",
"threw": "throw", "thrown": "throw", "throws": "throw", "throwing": "throw",
"told": "tell", "tells": "tell", "telling": "tell",
"left": "leave", "leaves": "leave", "leaving": "leave",
"grew": "grow", "grown": "grow", "grows": "grow", "growing": "grow",
"paid": "pay", "pays": "pay", "paying": "pay",
"wound": "wind", "winds": "wind", "winding": "wind",
"built": "build", "builds": "build", "building": "build",
"cut": "cut", "cuts": "cut", "cutting": "cut",
"shut": "shut", "shuts": "shut", "shutting": "shut",
"set": "set", "sets": "set", "setting": "set",
"split": "split", "splits": "split", "splitting": "split",
"spread": "spread", "spreads": "spread", "spreading": "spread",
}
PRONOUNS_AND_OBJECTS = {
"it", "him", "her", "them", "us", "me", "you", "this", "that", "these", "those",
"things", "something", "everything", "anything", "all", "back", "away"
}
# Curated high-frequency English phrasal verbs (CEFR A2-B2) with clean translations
CURATED_PHRASAL_VERBS: Dict[str, str] = {
# take
"take off": "взлетать; снимать (одежду); быстро уходить",
"take up": "браться за (хобби/дело); занимать (место, время)",
"take on": "брать на себя (ответственность); нанимать",
"take over": "захватывать контроль; вступать во владение",
"take in": "усваивать; приютить; обманывать",
"take out": "вынимать; приглашать (в ресторан); устранять",
"take back": "возвращать (вещь); брать свои слова назад",
"take after": "быть похожим на (родителей)",
"take apart": "разбирать на части",
# give
"give up": "сдаваться; бросать (привычку)",
"give in": "уступать, сдаваться под натиском",
"give away": "раздавать даром; выдавать (секрет)",
"give out": "раздавать; иссякать, отказывать",
"give back": "возвращать",
# get
"get along with": "ладить, быть в хороших отношениях с кем-то",
"get along": "ладить; справляться",
"get away": "убегать, уезжать в отпуск",
"get away with": "выходить сухим из воды",
"get back": "возвращаться",
"get by": "сводить концы с концами, выживать",
"get off": "выходить (из автобуса/поезда); отделываться",
"get on": "садиться (на транспорт); ладить; продолжать",
"get over": "преодолевать, оправляться (от болезни/потери)",
"get rid of": "избавляться от",
"get through": "справляться; дозваниваться",
"get up": "вставать (с постели); подниматься",
# go
"go on": "продолжать; происходить",
"go out": "выходить в свет; встречаться; гаснуть (об огне/свете)",
"go through": "проходить через, испытывать (трудности); просматривать",
"go back": "возвращаться",
"go off": "звонить (о будильнике); взрываться; портиться (о еде)",
"go over": "проверять, внимательно просматривать; повторять",
"go ahead": "начинать, продолжать; давать добро",
"go with": "сочетаться с, подходить к",
"go down": "спускаться, снижаться; входить в историю",
"go up": "подниматься, расти (о цене)",
# come
"come up with": "придумывать, предлагать (идею, решение)",
"come across": "случайно натолкнуться на, производить впечатление",
"come back": "возвращаться",
"come in": "входить",
"come out": "выходить (в свет); выясняться, обнаруживаться",
"come over": "приходить в гости; охватывать",
"come on": "давай!; появляться; начинаться",
"come up": "возникать, всплывать (о вопросе); подходить",
"come down to": "сводиться к",
# look
"look forward to": "ждать с нетерпением, предвкушать",
"look for": "искать",
"look after": "присматривать за, заботиться",
"look into": "исследовать, изучать, разбираться",
"look up": "искать (в словаре/справочнике); улучшаться",
"look up to": "уважать, брать пример с",
"look down on": "смотреть свысока, презирать",
"look out": "остерегаться, быть начеку",
"look around": "осматриваться",
# turn
"turn off": "выключать (прибор, свет, воду)",
"turn on": "включать (прибор, свет, воду)",
"turn up": "прибавлять громкость; неожиданно объявляться",
"turn down": "убавлять громкость; отклонять, отвергать",
"turn out": "оказываться; оборачиваться (результатом)",
"turn into": "превращаться в",
"turn back": "поворачивать назад, разворачиваться",
"turn over": "переворачивать; передавать",
# set
"set up": "устанавливать, организовывать, подготавливать",
"set off": "отправляться в путь; вызывать (реакцию)",
"set out": "отправляться в путь; намереваться",
# put
"put on": "надевать (одежду); включать; прибавлять в весе",
"put off": "откладывать (на потом); вызывать отвращение",
"put out": "тушить (огонь); выпускать (продукцию)",
"put up with": "мириться с, терпеть",
"put away": "убирать на место",
"put forward": "выдвигать (идею/предложение)",
# run
"run out of": "заканчиваться, исчерпать запасы",
"run out": "иссякать, заканчиваться",
"run into": "случайно встретить; сталкиваться",
"run away": "убегать, сбегать",
"run over": "переехать, сбить; повторить бегло",
# bring
"bring up": "воспитывать (детей); поднимать (тему в разговоре)",
"bring about": "вызывать, приводить к",
"bring back": "возвращать, вызывать воспоминания",
"bring in": "привлекать, вводить (закон/доход)",
# call
"call off": "отменять (встречу/мероприятие)",
"call back": "перезванивать; отзывать",
"call on": "навещать; призывать к ответу",
"call for": "требовать, предусматривать",
# carry
"carry out": "выполнять, приводить в исполнение (план/исследование)",
"carry on": "продолжать (дело/разговор)",
# break
"break down": "ломаться (о технике); терять самообладание",
"break up": "расставаться (об отношениях); распадаться",
"break into": "вламываться, незаконно проникать",
"break out": "разразиться (о войне/пожаре); сбегать (из тюрьмы)",
# fall
"fall apart": "разваливаться на куски, терпеть крах",
"fall behind": "отставать (в графике/учёбе)",
"fall for": "попасться на удочку; влюбиться по уши",
"fall out": "ссориться; выпадать",
# keep
"keep up with": "поспевать за, держаться наравне с",
"keep up": "поддерживать на том же уровне",
"keep on": "упорно продолжать",
"keep away": "держаться подальше, избегать",
# hold
"hold on": "подождать (на линии); крепко держаться",
"hold up": "задерживать; грабить",
"hold back": "сдерживать (эмоции/прогресс)",
# point
"point out": "указывать, обращать внимание на",
# figure
"figure out": "выяснить, понять, разобраться",
# find
"find out": "узнавать, выяснять (информацию)",
# show
"show up": "появляться, приходить (неожиданно)",
"show off": "выпендриваться, хвастаться",
# work
"work out": "тренироваться; решать проблему; складываться успешно",
# catch
"catch up with": "догонять, нагонять (упущенное/кого-то)",
"catch up": "догонять; наверстывать упущенное",
# check
"check in": "регистрироваться (в отеле/аэропорту)",
"check out": "выселяться; проверять, заценить",
# drop
"drop in": "заглянуть в гости без предупреждения",
"drop by": "зайти мимоходом",
"drop off": "подвезти (на машине); заснуть",
# end
"end up": "оказаться в итоге (где-то или в каком-то состоянии)",
# stand
"stand out": "выделяться, бросаться в глаза",
"stand for": "означать; выступать в защиту",
# cut
"cut down on": "сокращать потребление (сахара/расходов)",
"cut down": "срубать; сокращать",
"cut off": "отрезать, прерывать (связь)",
# fill
"fill in": "заполнять (пропуски/форму); вводить в курс дела",
"fill out": "заполнять (анкету/бланк)",
# hang
"hang out": "проводить время, тусоваться",
"hang up": "вешать телефонную трубку",
# pass
"pass away": "умирать, уходить из жизни",
"pass out": "терять сознание, отключаться",
# pick
"pick up": "подбирать; заезжать за кем-то; учить на лету",
# pull
"pull through": "справляться, выкарабкиваться (из кризиса/болезни)",
"pull over": "съехать на обочину, притормозить",
# other essentials
"back up": "поддерживать; создавать резервную копию",
"clear up": "проясняться (о погоде); убирать, наводить порядок",
"slow down": "замедляться",
"speed up": "ускоряться",
"switch on": "включать",
"switch off": "выключать",
"throw away": "выбрасывать (мусор)",
"try on": "примерять (одежду)",
"try out": "пробовать, испытывать",
"watch out": "остерегаться, беречься",
"write down": "записывать на бумаге",
"calm down": "успокаиваться",
"cheer up": "приободряться, поднимать настроение",
"make up for": "компенсировать, заглаживать вину",
"make up": "мириться; сочинять (историю); краситься",
"settle down": "остепениться; успокоиться",
"sort out": "улаживать, разбираться с проблемами",
"blow up": "взрываться; выходить из себя",
"burn down": "сгорать дотла",
"tear down": "сносить (здание)",
"wear out": "изнашиваться; утомлять",
"stick to": "придерживаться (плана/правил)",
"kick off": "начинать, давать старт",
"lock up": "запирать на замок",
"pay off": "окупаться; расплачиваться с долгами",
"count on": "рассчитывать на кого-то",
"stay up": "бодрствовать, не ложиться спать",
"speak up": "говорить громче; высказываться смело",
}
def lemmatize_word(w: str) -> List[str]:
"""Generates candidate base forms for English verbs and nouns."""
w = w.lower()
res = [w]
if w in IRREGULARS:
res.append(IRREGULARS[w])
if w.endswith("ing") and len(w) > 4:
stem = w[:-3]
res.extend([stem, stem + "e"])
if len(stem) > 2 and stem[-1] == stem[-2]:
res.append(stem[:-1])
elif w.endswith("ed") and len(w) > 3:
stem = w[:-2]
res.extend([stem, stem + "e"])
if stem.endswith("i"):
res.append(stem[:-1] + "y")
if len(stem) > 2 and stem[-1] == stem[-2]:
res.append(stem[:-1])
elif w.endswith("s") and len(w) > 3:
if w.endswith("es"):
res.extend([w[:-2], w[:-1]])
if w.endswith("ies"):
res.append(w[:-3] + "y")
else:
res.append(w[:-1])
# De-duplicate preserving order
return list(dict.fromkeys(res))
def detect_phrasal_verb(
sentence: str,
target_word: str,
user_id: Optional[int] = None,
conn: Optional[sqlite3.Connection] = None,
) -> Optional[Dict[str, Any]]:
"""
Detects if target_word in sentence is part of a phrasal verb or collocation.
Checks window sizes 4, 3, 2 and separable phrasal verbs.
Returns matched term, raw snippet in text, translation, separable flag, and library status.
"""
if not sentence or not target_word:
return None
tokens = re.findall(r"\b[a-zA-Z\'-]+\b", sentence)
lower_tokens = [t.lower() for t in tokens]
target_lower = target_word.strip().lower()
target_indices = [i for i, t in enumerate(lower_tokens) if t == target_lower]
if not target_indices:
return None
def execute_query(query: str, params: tuple):
if conn is not None:
return conn.execute(query, params).fetchone()
with get_db() as c:
return c.execute(query, params).fetchone()
match_result = None
for idx in target_indices:
# 1. Contiguous spans: 4, 3, 2 words containing target_word
for span_len in (4, 3, 2):
for start in range(max(0, idx - span_len + 1), min(len(tokens) - span_len + 1, idx + 1)):
span_tokens = lower_tokens[start : start + span_len]
raw_match = " ".join(tokens[start : start + span_len])
v_lemmas = lemmatize_word(span_tokens[0])
for v in v_lemmas:
cand = " ".join([v] + span_tokens[1:])
if cand in CURATED_PHRASAL_VERBS:
match_result = {
"term": cand,
"raw": raw_match,
"translation": CURATED_PHRASAL_VERBS[cand],
"separable": False,
"matched_words": tokens[start : start + span_len],
}
break
# Check offline dictionary
row = execute_query(
"SELECT term, translation FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(cand,),
)
if row:
match_result = {
"term": row[0],
"raw": raw_match,
"translation": row[1],
"separable": False,
"matched_words": tokens[start : start + span_len],
}
break
if match_result:
break
if match_result:
break
if match_result:
break
# 2. Separable phrasal verbs: verb + pronoun/short object + particle (e.g. turned it off -> turn off)
for v_idx in range(max(0, idx - 2), min(len(tokens) - 2, idx + 1)):
if lower_tokens[v_idx + 1] in PRONOUNS_AND_OBJECTS:
p_idx = v_idx + 2
if idx in (v_idx, p_idx):
v_lemmas = lemmatize_word(lower_tokens[v_idx])
particle = lower_tokens[p_idx]
raw_match = " ".join(tokens[v_idx : p_idx + 1])
for v in v_lemmas:
cand = f"{v} {particle}"
if cand in CURATED_PHRASAL_VERBS:
match_result = {
"term": cand,
"raw": raw_match,
"translation": CURATED_PHRASAL_VERBS[cand],
"separable": True,
"matched_words": [tokens[v_idx], tokens[p_idx]],
}
break
row = execute_query(
"SELECT term, translation FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(cand,),
)
if row:
match_result = {
"term": row[0],
"raw": raw_match,
"translation": row[1],
"separable": True,
"matched_words": [tokens[v_idx], tokens[p_idx]],
}
break
if match_result:
break
if match_result:
break
if not match_result:
return None
# Check if this phrasal verb already exists in user's vocabulary
match_result["in_library"] = False
match_result["library_card"] = None
if user_id is not None:
row = execute_query(
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps "
"FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
(user_id, match_result["term"]),
)
if row:
match_result["in_library"] = True
match_result["library_card"] = dict(row)
return match_result
def seed_curated_phrasal_verbs(conn: sqlite3.Connection):
"""Ensures all curated phrasal verbs are present in offline_dictionary table."""
try:
for term, trans in CURATED_PHRASAL_VERBS.items():
conn.execute(
"""
INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos)
VALUES (?, ?, '', 'verb phrase')
""",
(term, trans),
)
except Exception:
pass
+62 -1
View File
@@ -3847,10 +3847,71 @@ body {
padding: 4px; padding: 4px;
} }
.popup-close-btn:hover { /* Phrasal Verb & Collocation Card in Word Popup */
.popup-phrasal-card {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(168, 85, 247, 0.08));
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: var(--radius-sm);
padding: 8px 10px;
margin-bottom: 10px;
animation: fadeIn 0.15s ease-out;
}
.phrasal-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.phrasal-tag {
font-size: 10px;
font-weight: 700;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.phrasal-term-row {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 2px;
}
.phrasal-term {
font-size: 14px;
color: var(--text-main); color: var(--text-main);
} }
.phrasal-raw {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
}
.phrasal-translation {
font-size: 12px;
font-weight: 600;
color: var(--success);
margin-bottom: 6px;
line-height: 1.35;
}
.phrasal-card-actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
.phrasal-action-btn {
font-size: 11px;
padding: 3px 8px;
width: 100%;
justify-content: center;
}
.popup-translation { .popup-translation {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
+20
View File
@@ -1061,6 +1061,26 @@
<div class="popup-body"> <div class="popup-body">
<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">
<!-- Phrasal Verb & Idiom Detector Card -->
<div id="popup-phrasal-card" class="popup-phrasal-card hidden">
<div class="phrasal-card-header">
<span class="phrasal-tag">🔗 Фразовый глагол</span>
<span id="popup-phrasal-library-badge" class="popup-in-library-badge hidden">⭐ В библиотеке</span>
</div>
<div class="phrasal-card-body">
<div class="phrasal-term-row">
<strong id="popup-phrasal-term" class="phrasal-term">take off</strong>
<span id="popup-phrasal-raw" class="phrasal-raw">(в тексте: «took off»)</span>
</div>
<div id="popup-phrasal-translation" class="phrasal-translation">взлетать, снимать</div>
</div>
<div class="phrasal-card-actions">
<button type="button" id="popup-switch-phrasal-btn" class="btn btn-xs btn-primary phrasal-action-btn">
🔗 Переключить на фразовый глагол
</button>
</div>
</div>
<div class="popup-translation" id="popup-translation">перевод</div> <div class="popup-translation" id="popup-translation">перевод</div>
<div class="popup-context-box"> <div class="popup-context-box">
<div class="popup-context" id="popup-context">“Контекст из книги...”</div> <div class="popup-context" id="popup-context">“Контекст из книги...”</div>
+10 -2
View File
@@ -96,13 +96,21 @@ export const api = {
// Dictionary Lookup & AI Sentence Translation // Dictionary Lookup & AI Sentence Translation
lookup: { lookup: {
async search(term) { async search(term, context = '') {
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`); let url = `/api/lookup?term=${encodeURIComponent(term)}`;
if (context) {
url += `&context=${encodeURIComponent(context)}`;
}
const resp = await fetch(url);
if (resp.ok) { if (resp.ok) {
return await resp.json(); return await resp.json();
} }
throw new Error(`Lookup failed: ${resp.status}`); throw new Error(`Lookup failed: ${resp.status}`);
}, },
async detectPhrasalVerb(sentence, word) {
const resp = await fetch(`/api/detect-phrasal-verb?sentence=${encodeURIComponent(sentence)}&word=${encodeURIComponent(word)}`);
return resp.ok ? await resp.json() : { detected: false, phrasal_verb: null };
},
async translateSentence(sentence, targetWord = '') { async translateSentence(sentence, targetWord = '') {
const resp = await fetch('/api/translate-sentence', { const resp = await fetch('/api/translate-sentence', {
method: 'POST', method: 'POST',
+95 -8
View File
@@ -1160,6 +1160,17 @@ export async function openWordPopup(term, contextSentence, targetElement) {
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val'); const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar'); const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar');
const popupPhrasalCard = document.getElementById('popup-phrasal-card');
const popupPhrasalTerm = document.getElementById('popup-phrasal-term');
const popupPhrasalRaw = document.getElementById('popup-phrasal-raw');
const popupPhrasalTranslation = document.getElementById('popup-phrasal-translation');
const popupPhrasalLibraryBadge = document.getElementById('popup-phrasal-library-badge');
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
popupCurrentData.phrasalVerb = null;
popupCurrentData.isPhrasalMode = false;
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden'); if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden'); if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = ''; if (popupSentenceRu) popupSentenceRu.textContent = '';
@@ -1202,9 +1213,27 @@ export async function openWordPopup(term, contextSentence, targetElement) {
positionWordPopup(targetElement); positionWordPopup(targetElement);
try { try {
const data = await api.lookup.search(cleanTerm); const data = await api.lookup.search(cleanTerm, contextSentence);
popupCurrentData.lookup = data; popupCurrentData.lookup = data;
// Phrasal verb detection
if (data.phrasal_verb) {
const pv = data.phrasal_verb;
popupCurrentData.phrasalVerb = pv;
if (popupPhrasalCard) {
popupPhrasalCard.classList.remove('hidden');
if (popupPhrasalTerm) popupPhrasalTerm.textContent = pv.term;
if (popupPhrasalRaw) popupPhrasalRaw.textContent = `(в тексте: «${pv.raw}»)`;
if (popupPhrasalTranslation) popupPhrasalTranslation.textContent = pv.translation;
if (popupPhrasalLibraryBadge) popupPhrasalLibraryBadge.classList.toggle('hidden', !pv.in_library);
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
}
} else {
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
}
const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : ''); const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : '');
if (popupPhonetic) popupPhonetic.textContent = data.phonetic || ''; if (popupPhonetic) popupPhonetic.textContent = data.phonetic || '';
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден'; if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
@@ -1924,23 +1953,81 @@ export function initReader() {
}); });
} }
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.phrasalVerb) return;
const pv = popupCurrentData.phrasalVerb;
if (!popupCurrentData.isPhrasalMode) {
// Switch to phrasal verb mode
popupCurrentData.isPhrasalMode = true;
if (popupTerm) popupTerm.textContent = pv.term;
if (popupTranslation) popupTranslation.textContent = pv.translation;
if (popupPhonetic) popupPhonetic.textContent = '';
const inLib = Boolean(pv.in_library);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = `💾 Сохранить «${pv.term}» в словарь`;
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Фразовый глагол уже в библиотеке';
}
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
popupSwitchPhrasalBtn.textContent = `↩️ Вернуться к «${popupCurrentData.term}»`;
} else {
// Revert back to single word
popupCurrentData.isPhrasalMode = false;
const origTrans = popupCurrentData.lookup ? (popupCurrentData.lookup.translation || popupCurrentData.lookup.translation_ru || '') : '';
if (popupTerm) popupTerm.textContent = popupCurrentData.term;
if (popupTranslation) popupTranslation.textContent = origTrans;
if (popupPhonetic) popupPhonetic.textContent = popupCurrentData.lookup?.phonetic || '';
const inLib = Boolean(popupCurrentData.lookup?.in_library || popupCurrentData.existingCard);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = '💾 Добавить в словарь';
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
positionWordPopup(popupCurrentData.targetElement);
});
}
if (popupSaveBtn) { if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => { popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return; if (!popupCurrentData) return;
const d = popupCurrentData; const d = popupCurrentData;
const lookup = d.lookup || {}; const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader'; const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const isPv = Boolean(d.isPhrasalMode && d.phrasalVerb);
const termToSave = isPv ? d.phrasalVerb.term : d.term;
const transToSave = isPv
? d.phrasalVerb.translation
: (lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''));
const collectionToSave = isPv ? 'Фразовые глаголы & Идиомы' : 'Книги & Сериалы';
const tagsToSave = isPv ? `${bookTag}, phrasal-verbs, reader` : `${bookTag}, reader`;
const payload = { const payload = {
term: d.term, term: termToSave,
translation: lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''), translation: transToSave,
context_sentence: d.contextSentence || '', context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '', phonetic: isPv ? '' : (lookup.phonetic || ''),
audio_url: lookup.audio_url || '', audio_url: isPv ? '' : (lookup.audio_url || ''),
part_of_speech: lookup.part_of_speech || '', part_of_speech: isPv ? 'verb phrase' : (lookup.part_of_speech || ''),
definition_en: lookup.definition_en || '', definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы', collection: collectionToSave,
tags: `${bookTag}, reader`, tags: tagsToSave,
notes: currentBook ? `Из книги: ${currentBook.title}` : '', notes: currentBook ? `Из книги: ${currentBook.title}` : '',
}; };
+89
View File
@@ -1271,4 +1271,93 @@ def test_sentence_translation_ai_and_caching():
assert "active_model" in status_data assert "active_model" in status_data
def test_phrasal_verb_detector_unit_and_integration():
"""Validates phrasal verb lemmatization, context detection, separable verbs, and API integration."""
from app.phrasal import lemmatize_word, detect_phrasal_verb
# 1. Lemmatization tests
assert "take" in lemmatize_word("took")
assert "give" in lemmatize_word("giving")
assert "figure" in lemmatize_word("figured")
assert "run" in lemmatize_word("running")
# 2. Direct unit detection tests
# Contiguous 2-word phrasal verb: clicking verb or particle
res_verb = detect_phrasal_verb("The plane took off into the sky.", "took")
assert res_verb is not None
assert res_verb["term"] == "take off"
assert res_verb["raw"] == "took off"
assert res_verb["separable"] is False
res_part = detect_phrasal_verb("The plane took off into the sky.", "off")
assert res_part is not None
assert res_part["term"] == "take off"
# Contiguous 3-word phrasal verb
res_triple = detect_phrasal_verb("She came up with a great solution.", "came")
assert res_triple is not None
assert res_triple["term"] == "come up with"
assert res_triple["raw"] == "came up with"
res_fwd = detect_phrasal_verb("I look forward to our trip.", "forward")
assert res_fwd is not None
assert res_fwd["term"] == "look forward to"
# Separable phrasal verb
res_sep_v = detect_phrasal_verb("Please turn it off before leaving.", "turn")
assert res_sep_v is not None
assert res_sep_v["term"] == "turn off"
assert res_sep_v["separable"] is True
res_sep_p = detect_phrasal_verb("Please turn it off before leaving.", "off")
assert res_sep_p is not None
assert res_sep_p["term"] == "turn off"
assert res_sep_p["separable"] is True
# Negative case (no phrasal verb)
res_none = detect_phrasal_verb("The red apple fell onto the soft grass.", "apple")
assert res_none is None
# 3. HTTP Endpoint Integration
client.cookies.clear()
unauth = client.get("/api/detect-phrasal-verb?sentence=The+plane+took+off&word=took")
assert unauth.status_code == 401
login_res = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
assert login_res.status_code == 200
detect_res = client.get("/api/detect-phrasal-verb?sentence=The+plane+took+off&word=took")
assert detect_res.status_code == 200
data = detect_res.json()
assert data["detected"] is True
assert data["phrasal_verb"]["term"] == "take off"
# 4. Contextual integration inside /api/lookup
lookup_res = client.get("/api/lookup?term=took&context=The+plane+took+off+into+the+sky")
assert lookup_res.status_code == 200
lookup_data = lookup_res.json()
assert lookup_data["query"] == "took"
assert lookup_data.get("phrasal_verb") is not None
assert lookup_data["phrasal_verb"]["term"] == "take off"
assert "взлетать" in lookup_data["phrasal_verb"]["translation"]
assert lookup_data["phrasal_verb"]["in_library"] is False
# 5. In-library status check for phrasal verb
add_card = client.post("/api/cards", json={
"term": "take off",
"translation": "взлетать, снимать",
"context_sentence": "The plane took off into the sky.",
"collection": "Фразовые глаголы & Идиомы",
"tags": "phrasal-verbs, reader"
})
assert add_card.status_code == 200
lookup_after = client.get("/api/lookup?term=took&context=The+plane+took+off+into+the+sky")
assert lookup_after.status_code == 200
after_data = lookup_after.json()
assert after_data["phrasal_verb"]["in_library"] is True
assert after_data["phrasal_verb"]["library_card"] is not None