"""Полнотекстовый поиск 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_.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_
создаётся 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]