"""Доски, колонки и карточки дашборда (п.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}