""" 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" } 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_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 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( "SELECT term, translation, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE", (cand,), ) if row: pos_val = (row[2] or "").lower() if "verb" in pos_val: 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): 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]) 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, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE", (cand,), ) if row: pos_val = (row[2] or "").lower() if "verb" in pos_val: 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