-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsuggest.py
More file actions
149 lines (126 loc) · 9.36 KB
/
Copy pathsuggest.py
File metadata and controls
149 lines (126 loc) · 9.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""Подстановки, которые находятся сами - по истории диктовок.
Зачем. Страница «Слова» была формой, которую надо заполнять руками, и владелец
вынес ей приговор: «словари нужны каким-то айтишникам, никто это заполнять не
будет». Он прав, и доказательство лежало в самом проекте - демо-конфиг с
«Соколов, Кубернетес, Постгрес». Это список инженера, а не человека, который
пишет письма.
Голосовую команду «запомни: моя почта - такая-то» он отверг тем же доводом:
«это то же самое, только диктуешь вместо того, чтобы печатать». Тоже верно -
работа остаётся на человеке, меняется лишь способ ввода.
Значит источник должен быть один: то, что человек уже наговорил. Мы ищем в
истории то, что он повторяет, и предлагаем дать этому короткую фразу.
**Что мы НЕ делаем, и это важно.** Мы не придумываем, как эту фразу назвать.
Найти повтор машина может, а назвать его по-человечески - нет: «roman@почта»
это «моя почта», «рабочая» или «для счетов» - знает только владелец. Поэтому
находка приносит ЗНАЧЕНИЕ, а имя даёт человек. Одно поле вместо двух, и то
поле, которое без него не заполнить.
Всё считается локально и по уже сохранённой истории: ни одного нового байта
никуда не уходит.
"""
import re
from collections import Counter
# Точные штуки, которые диктовать мучительно, а ошибиться легко. Им хватает
# двух повторов: адрес почты, набранный дважды, - это уже привычка.
_EXACT = (
("почта", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b", re.UNICODE)),
("ссылка", re.compile(r"\bhttps?://\S+|\b[\w-]+\.(?:ru|com|org|net|io)/\S*",
re.IGNORECASE)),
("телефон", re.compile(r"(?:\+7|8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}")),
)
_MIN_EXACT = 2 # почте и телефону хватает двух раз
_MIN_PHRASE = 3 # обычной фразе - трёх: два раза бывает совпадением
_PHRASE_WORDS = (4, 14) # короче - шум («я думаю, что»), длиннее - не повторяют
_MAX_OUT = 3 # больше трёх предложений подряд - это уже навязывание
_WORD = re.compile(r"[\w@.+-]+", re.UNICODE)
def _worth_it(phrase: str) -> bool:
"""Есть ли в повторе содержание, ради которого стоит заводить подстановку.
Первый же прогон на живой истории нашёл «я хочу чтобы ты» - повторено
честные три раза, и совершенно бесполезно. Повторяются не только адреса и
реквизиты, но и служебные связки, которыми человек начинает каждую вторую
фразу. Предложить такое - значит выдать шум за находку и научить человека
закрывать наши предложения не читая.
Признак содержания берём грубый, но проверяемый: цифра, собака, заглавная
буква в середине (имя собственное) или хотя бы два длинных слова. «Собери
сводку по продажам за июнь» проходит, «я хочу чтобы ты» - нет. Это
эвристика, и её придётся править по живым находкам: сюда стоит смотреть,
когда предложения начнут раздражать.
"""
if any(ch.isdigit() for ch in phrase) or "@" in phrase:
return True
words = _WORD.findall(phrase)
# Заглавная не в первом слове - почти наверняка имя, название или город.
if any(w[:1].isupper() for w in words[1:]):
return True
return sum(1 for w in words if len(w) >= 6) >= 2
def _norm(s: str) -> str:
"""Для сравнения: регистр и хвостовая пунктуация не должны делать из одной
и той же фразы две разных."""
return s.strip(" .,!?;:«»\"'").lower().replace("ё", "е")
def find(rows: list, snippets: dict | None = None, skip: list | None = None) -> list:
"""Что стоит предложить. [{value, count, kind}], самое частое первым.
rows - записи истории: словари с ключом «text»;
snippets - уже заведённые подстановки: их значения не предлагаем повторно;
skip - от чего человек отказался: не возвращаемся к этому никогда.
"""
known = {_norm(v) for v in (snippets or {}).values()}
known |= {_norm(v) for v in (skip or [])}
texts = [str(r.get("text") or "") for r in rows if r.get("text")]
if len(texts) < _MIN_EXACT:
return []
out: list[dict] = []
# 1. Точные штуки. Считаем по числу диктовок, а не вхождений: адрес, дважды
# названный в одной фразе, - это не привычка, а перечисление.
for kind, rx in _EXACT:
seen = Counter()
for t in texts:
for m in {m.group(0) for m in rx.finditer(t)}:
seen[m] += 1
for value, count in seen.items():
if count >= _MIN_EXACT and _norm(value) not in known:
out.append({"value": value, "count": count, "kind": kind})
# 2. Повторяющиеся фразы. Только целыми словами и только те, что встретились
# в разных диктовках: одна длинная фраза, повторённая внутри себя, повтором
# не является.
lo, hi = _PHRASE_WORDS
phrase_docs: dict = {}
for i, t in enumerate(texts):
words = _WORD.findall(t)
here = set()
for n in range(lo, hi + 1):
for j in range(len(words) - n + 1):
here.add(" ".join(words[j:j + n]))
for ph in here:
phrase_docs.setdefault(_norm(ph), (ph, set()))[1].add(i)
phrases = [(orig, len(docs)) for _k, (orig, docs) in phrase_docs.items()
if len(docs) >= _MIN_PHRASE and _norm(orig) not in known
and _worth_it(orig)]
# Из вложенных и НАЕЗЖАЮЩИХ повторов берём один - самый длинный.
#
# Вложенность одна проверку не проходит, и это выяснилось на тесте. Одна
# фраза в одиннадцать слов даёт три окна по девять, сдвинутых на слово:
# «Собери сводку по продажам за июнь и пришли до», «сводку по продажам за
# июнь и пришли до конца», «по продажам за июнь и пришли до конца дня». Ни
# одно не содержит другое, так что проверка вложенности пропускала все три,
# и человек увидел бы три почти одинаковых предложения подряд. Ровно то, от
# чего он перестаёт их читать.
#
# Поэтому сравниваем по словам: если у кандидата больше половины слов общие
# с уже выбранным, это та же находка, только сдвинутая.
phrases.sort(key=lambda p: (-len(p[0]), -p[1]))
chosen: list = []
for text, count in phrases:
words = set(_norm(w) for w in _WORD.findall(text))
same = False
for c in chosen:
other = set(_norm(w) for w in _WORD.findall(c["value"]))
if len(words & other) / max(1, len(words)) > 0.5:
same = True
break
if same:
continue
chosen.append({"value": text, "count": count, "kind": "фраза"})
out.extend(chosen)
# Сначала самое частое, при равенстве - самое длинное: длинное экономит
# больше.
out.sort(key=lambda c: (-c["count"], -len(c["value"])))
return out[:_MAX_OUT]