From 1c3f03a64c730b352fe0dd761cf5adad56e0a837 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 6 Oct 2026 15:16:02 +0300 Subject: [PATCH] feat(reader): multi-word and phrasal verb detector in reader (Task 14) --- TODO.md | 11 +- app/database.py | 7 + app/main.py | 30 +++ app/phrasal.py | 419 ++++++++++++++++++++++++++++++++ app/static/css/style.css | 63 ++++- app/static/index.html | 20 ++ app/static/js/modules/api.js | 12 +- app/static/js/modules/reader.js | 103 +++++++- tests/test_api.py | 89 +++++++ 9 files changed, 738 insertions(+), 16 deletions(-) create mode 100644 app/phrasal.py diff --git a/TODO.md b/TODO.md index 193549a..a765d67 100644 --- a/TODO.md +++ b/TODO.md @@ -298,11 +298,12 @@ --- -### 14. 🔗 Детектор фразовых глаголов и идиом в читалке (Multi-Word / Phrasal Verb Detector) -- [ ] База частых английских фразовых глаголов и коллокаций (A2–B2: *take off, run out of, look forward to, figure out, set up, carry out* и др.). -- [ ] При клике на слово в читалке — автоматический анализ контекстного окна (1–2 слова до и после). -- [ ] При обнаружении устойчивой связки — выделение и перевод всей конструкции целиком вместо оторванного значения отдельного глагола (*took off* $\rightarrow$ «взлетел», а не «взял»). -- [ ] Сохранение готовой устойчивой связки в личный словарь в один клик. +### 14. 🔗 Детектор фразовых глаголов и идиом в читалке (Multi-Word / Phrasal Verb Detector) (Completed) +- [x] База частых английских фразовых глаголов и коллокаций (A2–B2: *take off, run out of, look forward to, figure out, set up, carry out, turn off, put up with* и др.). +- [x] При клике на слово в читалке — автоматический анализ контекстного окна (1–3 слова до и после + разделяемые фразовые глаголы *turn it off*). +- [x] При обнаружении устойчивой связки — карточка связки прямо в окне перевода слова (*took off* $\rightarrow$ «взлетать, снимать», а не «взял»). +- [x] Поддержка переключения между одиночным словом и фразовым глаголом в 1 клик. +- [x] Сохранение готовой устойчивой связки в личный словарь в один клик (в отдельную коллекцию «Фразовые глаголы & Идиомы» с тегом `#phrasal-verbs`). --- diff --git a/app/database.py b/app/database.py index abd9b03..7a2f909 100644 --- a/app/database.py +++ b/app/database.py @@ -205,6 +205,13 @@ def _migrate(conn: sqlite3.Connection): # Auto-seed offline dictionary if empty _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): """Seeds the offline dictionary from data/offline_dict.tsv.gz if the table is empty.""" diff --git a/app/main.py b/app/main.py index a54271c..126528e 100644 --- a/app/main.py +++ b/app/main.py @@ -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.reader import parse_epub, parse_text, parse_pdf, count_words 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( term: Optional[str] = None, q: Optional[str] = None, + context: Optional[str] = None, user: dict = Depends(require_user) ): query_str = (term or q or "").strip() @@ -319,9 +322,36 @@ async def api_lookup( data["in_library"] = False 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 +@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"]) async def api_audio_proxy(): diff --git a/app/phrasal.py b/app/phrasal.py new file mode 100644 index 0000000..af0cef8 --- /dev/null +++ b/app/phrasal.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css index a4d35e3..5cbb35f 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -3847,10 +3847,71 @@ body { 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); } +.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 { font-size: 15px; font-weight: 600; diff --git a/app/static/index.html b/app/static/index.html index e895111..f56d224 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1061,6 +1061,26 @@