feat(reader): multi-word and phrasal verb detector in reader (Task 14)
This commit is contained in:
1 parent
37a1f1ab35
commit
1c3f03a64c
9 files changed
+738
-16
No files matched your search
+419
@@ -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
|
||||
Reference in new issue
Block a user