Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
552 lines
25 KiB
Python
552 lines
25 KiB
Python
"""Доски, колонки и карточки дашборда (п.4.4, 4.5, 4.7 ТЗ).
|
|
|
|
Сюда же входят правила хранения (автоархив/очистка) и обучающие примеры
|
|
(действия пользователя -> learning_log).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
from .. import constants as C
|
|
from ..db import store
|
|
from ..sse import broker
|
|
from . import fts as fts_svc
|
|
from . import ml_client
|
|
from . import processing as processing_svc
|
|
from .pipeline import (
|
|
build_contacts,
|
|
clean_block,
|
|
clean_short,
|
|
compose_summary,
|
|
lead_to_dict,
|
|
normalize_stack,
|
|
primary_contact,
|
|
qualify_contact,
|
|
)
|
|
from .rules import board_accepts, extract_amounts, has_active_rules, hits_for_board
|
|
|
|
log = logging.getLogger("leadradar.leads")
|
|
|
|
KANBAN_COLS = ("inbox",) # + доски
|
|
|
|
|
|
def _now() -> int:
|
|
return time.time_ns() // 1_000_000
|
|
|
|
|
|
def _log_learning(lead_id: str, action: str, from_col: str | None, to_col: str | None) -> None:
|
|
store.execute(
|
|
"INSERT INTO learning_log(id, lead_id, action, from_col, to_col, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
[store.uid("lm_"), lead_id, action, from_col, to_col, _now()],
|
|
)
|
|
|
|
|
|
# ─── Доски / колонки ──────────────────────────────────────────────────────
|
|
|
|
def list_boards() -> list[dict]:
|
|
rows = store.query("SELECT * FROM boards ORDER BY suggested, pos")
|
|
return [
|
|
{
|
|
"id": r["id"],
|
|
"name": r["name"],
|
|
"description": r["description"] or "",
|
|
"color": r["color"],
|
|
"width": r["width"],
|
|
"collapsed": bool(r["collapsed"]),
|
|
"keywords": json.loads(r["keywords"] or "[]"),
|
|
"prompt": r["prompt"] or "",
|
|
"visibleFields": json.loads(r["visible_fields"] or "[]"),
|
|
"suggested": bool(r["suggested"]),
|
|
"rules": json.loads(r["rules"] or "{}"),
|
|
"note": r["note"] or "",
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def board_by_id(board_id: str) -> dict | None:
|
|
return next((b for b in list_boards() if b["id"] == board_id), None)
|
|
|
|
|
|
def create_board(
|
|
name: str,
|
|
color: str | None = None,
|
|
keywords: list | None = None,
|
|
prompt: str = "",
|
|
description: str = "",
|
|
suggested: bool = False,
|
|
rules: dict | None = None,
|
|
note: str = "",
|
|
) -> dict:
|
|
"""Создаёт колонку. suggested=TRUE — ИИ-предложение, ждёт решения пользователя."""
|
|
board_id = store.uid("b_")
|
|
pos = int(store.scalar("SELECT COALESCE(MAX(pos), -1) + 1 FROM boards"))
|
|
store.execute(
|
|
"INSERT INTO boards(id, name, description, color, width, pos, keywords, prompt, visible_fields, collapsed, suggested, rules, note, created_at) "
|
|
"VALUES (?, ?, ?, ?, 'md', ?, ?, ?, '[\"budget\",\"stack\",\"contacts\"]', FALSE, ?, ?, ?, ?)",
|
|
[
|
|
board_id,
|
|
name.strip() or "Новая колонка",
|
|
(description or "").strip(),
|
|
color or C.PALETTE[pos % len(C.PALETTE)],
|
|
pos,
|
|
json.dumps(keywords or [], ensure_ascii=False),
|
|
prompt or "",
|
|
bool(suggested),
|
|
json.dumps(rules or {}, ensure_ascii=False),
|
|
note or "",
|
|
_now(),
|
|
],
|
|
)
|
|
return {"id": board_id}
|
|
|
|
|
|
def patch_board(board_id: str, patch: dict) -> dict:
|
|
row = store.query_one("SELECT * FROM boards WHERE id = ?", [board_id])
|
|
if not row:
|
|
raise KeyError(board_id)
|
|
allowed = {"name", "description", "color", "width", "collapsed", "prompt", "suggested", "note"}
|
|
for key in allowed:
|
|
if key in patch and patch[key] is not None:
|
|
store.execute(f"UPDATE boards SET {key} = ? WHERE id = ?", [patch[key], board_id])
|
|
if "keywords" in patch and patch["keywords"] is not None:
|
|
store.execute("UPDATE boards SET keywords = ? WHERE id = ?", [json.dumps(patch["keywords"], ensure_ascii=False), board_id])
|
|
if "visibleFields" in patch and patch["visibleFields"] is not None:
|
|
store.execute("UPDATE boards SET visible_fields = ? WHERE id = ?", [json.dumps(patch["visibleFields"], ensure_ascii=False), board_id])
|
|
if "rules" in patch and patch["rules"] is not None:
|
|
store.execute("UPDATE boards SET rules = ? WHERE id = ?", [json.dumps(patch["rules"], ensure_ascii=False), board_id])
|
|
return {"id": board_id}
|
|
|
|
|
|
def delete_board(board_id: str) -> int:
|
|
"""Карточки доски уходят в «Неразобранное» (с пометкой новых)."""
|
|
leads = store.query("SELECT id FROM leads WHERE col = ?", [board_id])
|
|
for l in leads:
|
|
store.execute("UPDATE leads SET col = 'inbox', is_new = TRUE, prev_col = 'inbox' WHERE id = ?", [l["id"]])
|
|
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
|
|
return len(leads)
|
|
|
|
|
|
def reorder_boards(order: list[str]) -> None:
|
|
for i, board_id in enumerate(order):
|
|
store.execute("UPDATE boards SET pos = ? WHERE id = ?", [i, board_id])
|
|
|
|
|
|
def get_col_state() -> dict:
|
|
return store.get_setting("colState") or {}
|
|
|
|
|
|
def set_col_state(col_id: str, state: dict) -> dict:
|
|
current = store.get_setting("colState") or {}
|
|
current[col_id] = state
|
|
store.set_setting("colState", current)
|
|
return current[col_id]
|
|
|
|
|
|
# ─── Лиды ─────────────────────────────────────────────────────────────────
|
|
|
|
def list_leads(col: str | None = None) -> list[dict]:
|
|
if col:
|
|
rows = store.query("SELECT id FROM leads WHERE col = ? ORDER BY received_at DESC", [col])
|
|
else:
|
|
rows = store.query("SELECT id FROM leads WHERE col NOT IN ('taken') ORDER BY received_at DESC")
|
|
return [lead_to_dict(r["id"]) for r in rows]
|
|
|
|
|
|
def get_lead(lead_id: str) -> dict | None:
|
|
return lead_to_dict(lead_id) or None
|
|
|
|
|
|
def _move(lead_id: str, to_col: str, action: str = "move") -> None:
|
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
|
if not lead or lead["col"] == to_col:
|
|
return
|
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
|
# при переносе пересчитываем «почему карточка в колонке» (для архив/корзина — пусто)
|
|
hits = hits_for_board(to_col, text) if to_col not in ("inbox", "trash", "archive") else []
|
|
store.execute(
|
|
"UPDATE leads SET col = ?, is_new = FALSE, prev_col = ?, match_hits = ? WHERE id = ?",
|
|
[to_col, lead["col"], json.dumps(hits, ensure_ascii=False), lead_id],
|
|
)
|
|
_log_learning(lead_id, action, lead["col"], to_col)
|
|
|
|
|
|
def move_lead(lead_id: str, to_col: str, teach: bool = True) -> None:
|
|
"""Перенос между канбаном (Неразобранное и доски); архив/корзина не цели переноса.
|
|
|
|
teach=False — «тихое» перемещение без обучения (используется при ручной
|
|
разметке в ML-лаборатории, где обучение кладётся явно одним событием).
|
|
"""
|
|
if to_col not in ("inbox",) and store.query_one("SELECT 1 FROM boards WHERE id = ?", [to_col]) is None:
|
|
raise ValueError("Переносить можно только на доски или в «Неразобранное»")
|
|
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
|
|
_move(lead_id, to_col)
|
|
# ML обучается всегда: текст -> выбранная доска
|
|
if teach and lead and to_col != "inbox" and to_col != lead["col"]:
|
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
|
if text:
|
|
ml_client.push(text, to_col)
|
|
|
|
|
|
def trash_lead(lead_id: str, teach: bool = True) -> None:
|
|
lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id])
|
|
_move(lead_id, "trash", action="trash")
|
|
# «в корзину» = спам/не то: ML запоминает (обучение всегда)
|
|
if teach and lead and lead["col"] != "trash" and lead["col"] != "archive":
|
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
|
if text:
|
|
ml_client.push(text, "spam")
|
|
|
|
|
|
def restore_lead(lead_id: str) -> str:
|
|
"""Возврат из архива/корзины — только на канбан."""
|
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
|
if not lead:
|
|
raise KeyError(lead_id)
|
|
back = lead["prev_col"] if lead["prev_col"] in ("inbox",) or store.query_one("SELECT 1 FROM boards WHERE id = ?", [lead["prev_col"]]) else "inbox"
|
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
|
hits = hits_for_board(back, text) if back not in ("inbox", "trash", "archive") else []
|
|
store.execute(
|
|
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox', archived_at = NULL, match_hits = ? WHERE id = ?",
|
|
[back, json.dumps(hits, ensure_ascii=False), lead_id],
|
|
)
|
|
_log_learning(lead_id, "restore", lead["col"], back)
|
|
# возврат из корзины = не спам: снимаем метку
|
|
if lead["col"] == "trash":
|
|
text = (lead["source_msg"] or "").strip() or (lead["title"] or "")
|
|
if text:
|
|
ml_client.push(text, "spam", delta=-1.0)
|
|
return back
|
|
|
|
|
|
def _hard_delete(lead_id: str) -> None:
|
|
"""Полное удаление карточки: leads + dedup (иначе «сирота» заблокирует
|
|
повторное создание той же карточки при перечитывании) + отвязка исходника."""
|
|
store.execute("DELETE FROM leads WHERE id = ?", [lead_id])
|
|
store.execute("DELETE FROM dedup WHERE lead_id = ?", [lead_id])
|
|
store.execute("UPDATE messages SET lead_id = NULL WHERE lead_id = ?", [lead_id])
|
|
|
|
|
|
def delete_forever(lead_id: str) -> None:
|
|
_hard_delete(lead_id)
|
|
|
|
|
|
def clear_col(col: str) -> int:
|
|
"""Полная ручная очистка служебной колонки (корзина/архив) — безвозвратно."""
|
|
if col not in ("trash", "archive"):
|
|
raise ValueError("Очищать можно только корзину или архив")
|
|
ids = [r["id"] for r in store.query("SELECT id FROM leads WHERE col = ?", [col])]
|
|
if not ids:
|
|
return 0
|
|
store.execute("DELETE FROM leads WHERE col = ?", [col])
|
|
for lead_id in ids:
|
|
_hard_delete(lead_id)
|
|
return len(ids)
|
|
|
|
|
|
def mark_seen(lead_id: str | None = None, col: str | None = None) -> None:
|
|
if lead_id:
|
|
store.execute("UPDATE leads SET is_new = FALSE WHERE id = ?", [lead_id])
|
|
elif col:
|
|
store.execute("UPDATE leads SET is_new = FALSE WHERE col = ?", [col])
|
|
else:
|
|
store.execute("UPDATE leads SET is_new = FALSE")
|
|
|
|
|
|
def add_comment(lead_id: str, text: str) -> list[dict]:
|
|
lead = store.query_one("SELECT comments FROM leads WHERE id = ?", [lead_id])
|
|
comments = json.loads(lead["comments"] or "[]")
|
|
comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"})
|
|
store.execute("UPDATE leads SET comments = ? WHERE id = ?", [json.dumps(comments, ensure_ascii=False), lead_id])
|
|
_log_learning(lead_id, "comment", None, None)
|
|
return comments
|
|
|
|
|
|
def counts() -> dict:
|
|
"""Счётчики по колонкам, новые + статистика ML/ИИ (локальная, без HTTP)."""
|
|
out = {"new": 0}
|
|
rows = store.query("SELECT col, count(*) AS cnt, sum(CASE WHEN is_new THEN 1 ELSE 0 END) AS fresh FROM leads GROUP BY col")
|
|
for r in rows:
|
|
out[r["col"]] = {"count": r["cnt"], "new": r["fresh"] or 0}
|
|
out["new"] = sum((v["new"] for k, v in out.items() if isinstance(v, dict)), 0)
|
|
snap = ml_client.snapshot()
|
|
out["learning"] = snap["learning"]
|
|
out["ml"] = snap["ml"]
|
|
out["ai"] = snap["ai"]
|
|
return out
|
|
|
|
|
|
# ─── Пакетная переклассификация (Inbox) ──────────────────────────────────
|
|
|
|
# Фоновая задача переклассификации (одна; повторный вызов возвращает busy)
|
|
_reclassify_task: object | None = None
|
|
|
|
|
|
def reclassify_busy() -> bool:
|
|
return bool(_reclassify_task and not _reclassify_task.done())
|
|
|
|
|
|
async def reclassify_lead(lead_id: str) -> dict | None:
|
|
"""Прогнать карточку «Неразобранного» через полный конвейер ИИ.
|
|
|
|
Этап 2 (ИИ-фильтр) + классификатор; мусор/спам/служебные сообщения
|
|
отправляются в корзину (с обучением ML). Вернувшееся None — карточка
|
|
без исходного текста или не найденная.
|
|
"""
|
|
lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id])
|
|
if not lead or not lead["source_msg"]:
|
|
return None
|
|
from .ai import budget_to_target, classify, clean_budget, filter_incoming
|
|
|
|
text = lead["source_msg"]
|
|
try:
|
|
r2 = await filter_incoming(text)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("reclassify filter fail %s: %s", lead_id, exc)
|
|
r2 = {"pass": True, "reason": None, "skipped": True}
|
|
if not r2["pass"]:
|
|
trash_lead(lead_id, teach=False)
|
|
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
|
|
return {"status": "trashed", "reason": str(r2.get("reason") or "не прошло ИИ-фильтр")[:120]}
|
|
raw = await classify(text)
|
|
if raw.get("is_spam"):
|
|
trash_lead(lead_id, teach=False)
|
|
ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT)
|
|
return {"status": "trashed", "reason": "ИИ: не заявка/спам"}
|
|
board_id = str(raw.get("board") or "").strip() or None
|
|
# страховка: колонку с активными правилами может назначить только текст,
|
|
# прошедший эти правила (иначе ручная переклассификация закидывает хлам)
|
|
if board_id and not board_accepts(board_id, text):
|
|
board_id = None
|
|
budget = clean_budget(raw.get("budget"))
|
|
contacts = build_contacts(raw.get("contacts"), text)
|
|
contact = primary_contact(contacts)[:200]
|
|
if not contact:
|
|
# старый контакт оставляем только если он валидный (@, телефон, почта…),
|
|
# а не мусорная фраза из старого разбора
|
|
old = str(lead["contact"] or "").strip()[:200]
|
|
contact = old if old and qualify_contact(old) else ""
|
|
stack = normalize_stack(raw.get("stack"))
|
|
new_title = clean_short(raw.get("title") or "", 140) or clean_short(lead["title"], 140)
|
|
new_summary = clean_block(compose_summary(raw, text), 2000) or clean_block(lead["summary"], 2000)
|
|
if not budget:
|
|
# ИИ не выделил бюджет полем, но сумма с валютой есть в исходнике или
|
|
# в структурированной «О заявке» — показываем её на карточке.
|
|
for src in (text, new_summary):
|
|
amts = extract_amounts(src or "")
|
|
if amts:
|
|
a = amts[0]
|
|
budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]}
|
|
break
|
|
conv = budget_to_target(budget)
|
|
hits = hits_for_board(board_id, text) if board_id else []
|
|
store.execute(
|
|
"UPDATE leads SET col = ?, title = ?, summary = ?, is_vacancy = ?, is_vacancy_known = TRUE, "
|
|
"stack = ?, budget_from = ?, budget_to = ?, budget_cur = ?, "
|
|
"conv_from = ?, conv_to = ?, conv_cur = ?, contact = ?, contacts = ?, "
|
|
"match_hits = ?, is_new = TRUE "
|
|
"WHERE id = ?",
|
|
[
|
|
board_id or "inbox",
|
|
new_title,
|
|
new_summary,
|
|
bool(raw.get("is_vacancy")),
|
|
json.dumps(stack, ensure_ascii=False),
|
|
budget.get("from") if budget else None,
|
|
budget.get("to") if budget else None,
|
|
budget.get("currency", "") if budget else "",
|
|
conv["convFrom"], conv["convTo"], conv["convCur"],
|
|
contact,
|
|
json.dumps(contacts, ensure_ascii=False),
|
|
json.dumps(hits, ensure_ascii=False),
|
|
lead_id,
|
|
],
|
|
)
|
|
# ИИ-решение при переклассификации — тоже обучающий сигнал для ML.
|
|
# Учим только «свободные» колонки (без активных правил, не suggested):
|
|
# именно их ML может назначать сама в своём пути.
|
|
if board_id:
|
|
br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [board_id])
|
|
free = False
|
|
if br and not bool(br["suggested"]):
|
|
try:
|
|
br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {}
|
|
except Exception: # noqa: BLE001
|
|
br_rules = {}
|
|
free = not has_active_rules(br_rules)
|
|
if free:
|
|
ml_client.push(text, board_id, delta=ml_client.AI_WEIGHT)
|
|
# тип известен от ИИ — учим ML определять его сам (t:hire / t:order)
|
|
if not bool(raw.get("is_spam")):
|
|
ml_client.push(
|
|
text,
|
|
"t:hire" if bool(raw.get("is_vacancy")) else "t:order",
|
|
delta=ml_client.AI_WEIGHT,
|
|
)
|
|
return {"status": "moved" if board_id else "kept"}
|
|
|
|
|
|
async def reclassify_inbox(ids: list[str] | None = None) -> dict:
|
|
"""Переклассифицировать «Неразобранное» (все карточки или выбранные)."""
|
|
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
|
|
if ids:
|
|
wanted = set(ids)
|
|
target = [r["id"] for r in rows if r["id"] in wanted]
|
|
else:
|
|
target = [r["id"] for r in rows]
|
|
res = {"attempted": len(target), "kept": 0, "moved": 0, "trashed": 0}
|
|
for lead_id in target:
|
|
try:
|
|
out = await reclassify_lead(lead_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("reclassify %s failed: %s", lead_id, exc)
|
|
continue
|
|
if not out:
|
|
continue
|
|
status = out.get("status")
|
|
if status == "trashed":
|
|
res["trashed"] += 1
|
|
elif status == "moved":
|
|
res["moved"] += 1
|
|
else:
|
|
res["kept"] += 1
|
|
await broker.publish("leads_reclassified", res)
|
|
return res
|
|
|
|
|
|
async def start_reclassify(ids: list[str] | None = None) -> dict:
|
|
"""Запустить переклассификацию в фоне (одна задача за раз)."""
|
|
global _reclassify_task
|
|
if not store.get_setting("aiEnabled"):
|
|
return {"started": False, "busy": False, "attempted": 0, "reason": "ИИ выключен — переклассификация недоступна"}
|
|
if reclassify_busy():
|
|
return {"started": False, "busy": True}
|
|
rows = store.query("SELECT id FROM leads WHERE col = 'inbox'")
|
|
if ids:
|
|
wanted = set(ids)
|
|
target = [r["id"] for r in rows if r["id"] in wanted]
|
|
else:
|
|
target = [r["id"] for r in rows]
|
|
if not target:
|
|
return {"started": False, "busy": False, "attempted": 0}
|
|
|
|
async def _run() -> None:
|
|
try:
|
|
res = await reclassify_inbox(ids)
|
|
await broker.publish_toast(
|
|
f"Переклассификация готова: {res['trashed']} в корзину, "
|
|
f"{res['moved']} в колонки, {res['kept']} осталось в «Неразобранном»",
|
|
"sparkles",
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("reclassify task error: %s", exc)
|
|
await broker.publish_toast("Переклассификация завершилась с ошибкой", "x")
|
|
|
|
_reclassify_task = asyncio.create_task(_run())
|
|
return {"started": True, "busy": False, "attempted": len(target)}
|
|
|
|
|
|
# ─── Правила хранения (тик раз в 30 секунд) ───────────────────────────────
|
|
|
|
def tick_storage() -> dict:
|
|
auto = bool(store.get_setting("autoArchive"))
|
|
after_days = int(store.get_setting("archiveAfterDays") or 14)
|
|
archive_clear = int(store.get_setting("archiveClearDays") or 90)
|
|
trash_clear = int(store.get_setting("trashClearDays") or 7)
|
|
now = _now()
|
|
archived = purged_arch = purged_trash = 0
|
|
|
|
if auto:
|
|
rows = store.query(
|
|
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards UNION ALL SELECT 'inbox') "
|
|
"AND received_at < ?",
|
|
[now - after_days * C.DAY_MS],
|
|
)
|
|
for r in rows:
|
|
store.execute(
|
|
"UPDATE leads SET col = 'archive', is_new = FALSE, archived_at = ? WHERE id = ?",
|
|
[now, r["id"]],
|
|
)
|
|
archived += 1
|
|
|
|
old_arch = store.query("SELECT id FROM leads WHERE col = 'archive' AND archived_at IS NOT NULL AND archived_at < ?", [now - archive_clear * C.DAY_MS])
|
|
for r in old_arch:
|
|
_hard_delete(r["id"])
|
|
purged_arch += 1
|
|
|
|
old_trash = store.query("SELECT id FROM leads WHERE col = 'trash' AND received_at < ?", [now - trash_clear * C.DAY_MS])
|
|
for r in old_trash:
|
|
_hard_delete(r["id"])
|
|
purged_trash += 1
|
|
|
|
# отсев пайплайна живёт 3 суток, дальше удаляется автоматически
|
|
purged_rejected = processing_svc.purge_expired()
|
|
|
|
return {
|
|
"archived": archived,
|
|
"purgedArchive": purged_arch,
|
|
"purgedTrash": purged_trash,
|
|
"purgedRejected": purged_rejected,
|
|
}
|
|
|
|
|
|
async def notify_tick_stats(stats: dict) -> None:
|
|
if stats["archived"]:
|
|
await broker.publish_toast(f"Автоархив: {stats['archived']} карточек", "clock")
|
|
if stats["purgedArchive"]:
|
|
await broker.publish_toast(f"Архив очищен: {stats['purgedArchive']} (90 дн.)", "trash")
|
|
if stats["purgedTrash"]:
|
|
await broker.publish_toast(f"Корзина очищена: {stats['purgedTrash']} (7 дн.)", "trash")
|
|
if stats.get("purgedRejected"):
|
|
await broker.publish_toast(f"Отсев очищен: {stats['purgedRejected']} записей (3 дн.)", "trash")
|
|
|
|
|
|
# ─── Поиск ────────────────────────────────────────────────────────────────
|
|
|
|
def search(q: str, limit: int = 12) -> dict:
|
|
qq = q.strip().lower()
|
|
if len(qq) < 2:
|
|
return {"leads": [], "messages": []}
|
|
pattern = f"%{qq}%"
|
|
|
|
# FTS-кандидаты (снимок индекса)
|
|
fts_ids: list[str] = []
|
|
if fts_svc.is_ready():
|
|
try:
|
|
fts_ids = fts_svc.search(qq, limit=limit)["leads"]
|
|
except Exception: # noqa: BLE001
|
|
fts_ids = []
|
|
|
|
# LIKE-дополнение (свежие записи после последнего rebuild)
|
|
like_ids = [
|
|
r["id"]
|
|
for r in store.query(
|
|
"SELECT id FROM leads WHERE col != 'taken' AND ("
|
|
" lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(contact) LIKE ? OR lower(source_msg) LIKE ?) "
|
|
"ORDER BY received_at DESC LIMIT ?",
|
|
[pattern, pattern, pattern, pattern, limit * 2],
|
|
)
|
|
]
|
|
|
|
merged: list[str] = []
|
|
for bid in [*fts_ids, *like_ids]:
|
|
if bid not in merged:
|
|
merged.append(bid)
|
|
|
|
leads = []
|
|
for lid in merged:
|
|
d = lead_to_dict(lid)
|
|
if d and d["col"] != "taken":
|
|
leads.append(d)
|
|
if len(leads) >= limit:
|
|
break
|
|
|
|
msgs = store.query(
|
|
"SELECT id, dialog_id, text, msg_at FROM messages WHERE lower(text) LIKE ? ORDER BY msg_at DESC LIMIT 20",
|
|
[pattern],
|
|
)
|
|
return {"leads": leads, "messages": msgs}
|