Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
"""Полнотекстовый поиск DuckDB FTS (по решению — включаем).
|
|
|
|
DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при
|
|
старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild).
|
|
Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с
|
|
точечным LIKE-дополнением (см. services/leads.search).
|
|
|
|
Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index
|
|
(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает).
|
|
Поиск — через табличную функцию fts_main_<table>.match_bm25(rowid, query).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from ..db import store
|
|
|
|
log = logging.getLogger("leadradar.fts")
|
|
|
|
OK_KEY = "ftsOk"
|
|
|
|
# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py)
|
|
_FTS_TARGETS = [
|
|
("leads", ("title", "summary", "source_msg", "contact")),
|
|
("messages", ("text",)),
|
|
("rejected_msgs", ("text",)),
|
|
]
|
|
|
|
|
|
def is_ready() -> bool:
|
|
return bool(store.get_setting(OK_KEY))
|
|
|
|
|
|
def _set(ok: bool) -> None:
|
|
store.set_setting(OK_KEY, ok)
|
|
|
|
|
|
def install_extension() -> bool:
|
|
try:
|
|
store.execute("INSTALL fts")
|
|
store.execute("LOAD fts")
|
|
return True
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("FTS extension unavailable: %s", exc)
|
|
return False
|
|
|
|
|
|
def rebuild() -> bool:
|
|
"""Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого).
|
|
|
|
PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются
|
|
напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод.
|
|
"""
|
|
try:
|
|
for table, cols in _FTS_TARGETS:
|
|
col_list = ", ".join(f"'{c}'" for c in cols)
|
|
store.execute(
|
|
f"PRAGMA create_fts_index('{table}', 'id', {col_list}, "
|
|
"stemmer='russian', overwrite=1)"
|
|
)
|
|
_set(True)
|
|
log.info("FTS rebuild done")
|
|
return True
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("FTS rebuild failed: %s", exc)
|
|
_set(False)
|
|
return False
|
|
|
|
|
|
def search(query: str, limit: int = 50) -> dict:
|
|
"""Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS
|
|
(пусто при сбое)."""
|
|
out: dict = {"leads": [], "messages": [], "rejected": []}
|
|
if not is_ready():
|
|
return out
|
|
q = _sanitize(query)
|
|
if not q:
|
|
return out
|
|
targets = ("leads", "messages", "rejected_msgs")
|
|
keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"}
|
|
for table in targets:
|
|
try:
|
|
# fts_main_<table> создаётся PRAGMA create_fts_index над таблицей
|
|
fn = f"fts_main_{table}.match_bm25(id, ?)"
|
|
rows = store.query(
|
|
f"SELECT id, {fn} AS _s FROM {table} "
|
|
f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?",
|
|
[q, q, limit * 2],
|
|
)
|
|
out[keys[table]] = [r["id"] for r in rows]
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("fts %s query failed: %s", table, exc)
|
|
return out
|
|
|
|
|
|
def _sanitize(query: str) -> str:
|
|
"""Минимальная очистка запроса от служебных символов FTS."""
|
|
import re
|
|
|
|
q = query.strip().lower()
|
|
q = re.sub(r'["\'\\()!*+-]', " ", q)
|
|
q = " ".join(q.split())
|
|
return q[:80]
|