Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
"""Доски, колонки и карточки дашборда (п.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}
|
||||
Reference in New Issue
Block a user