"""Дашборд: доски, колонки, карточки, поиск, правила хранения.""" from __future__ import annotations import secrets import time from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from .. import config from .. import constants as C from ..auth import current_login from ..db import store from ..sse import broker from ..services import fts as fts_svc from ..services import leads as leads_svc from ..services.ai import normalize_dedup from ..services.pipeline import _store_lead, lead_to_dict, stage1_plain from ..services.rates import refresh_rates # noqa: F401 (для админ-тика может пригодиться) router = APIRouter(prefix="/api", tags=["dashboard"]) class BoardCreate(BaseModel): name: str description: str = "" color: str | None = None keywords: list[str] | None = None prompt: str = "" rules: dict | None = None class BoardPatch(BaseModel): name: str | None = None description: str | None = None color: str | None = None width: str | None = None collapsed: bool | None = None prompt: str | None = None keywords: list[str] | None = None visibleFields: list[str] | None = None suggested: bool | None = None rules: dict | None = None note: str | None = None class OrderBody(BaseModel): order: list[str] class ColStateBody(BaseModel): collapsed: bool | None = None width: str | None = None class MoveBody(BaseModel): to: str class CommentBody(BaseModel): text: str class ReclassifyBody(BaseModel): ids: list[str] | None = None class PumpGateBody(BaseModel): limit: int | None = None # 0/None — выключить; >0 — остановить воркер после N созданных карточек class CheckMessageBody(BaseModel): text: str # Демо-пул для кнопки «Симулировать лид» (без обращения к ИИ) _DEMO_POOL = [ ("Нужен Middle Python-разработчик на бота для CRM, удалённо, 1 600–2 200$.", {"title": "Middle Python-разработчик на бота для CRM", "summary": "Развитие CRM: бот для менеджеров, интеграция с amoCRM.", "stack": ["Python", "aiogram", "amoCRM"], "budget": {"from": 1600, "to": 2200, "currency": "USD"}, "contacts": "@crm_head", "is_vacancy": True, "board": None, "is_spam": False}), ("Frontend-разработчик в продуктовую команду, Vue 3, 2 400$.", {"title": "Frontend-разработчик в продуктовую команду", "summary": "Развитие SPA: компоненты, стейт, производительность.", "stack": ["Vue 3", "Pinia", "Vitest"], "budget": {"from": 2400, "to": 2400, "currency": "USD"}, "contacts": "@front_hh", "is_vacancy": True, "board": None, "is_spam": False}), ("Ищу подрядчика на бота для такси: клиент заказывает, водитель принимает. Бюджет обсуждаем.", {"title": "Бот для заказов такси", "summary": "Клиент заказывает, водитель принимает.", "stack": [], "budget": None, "contacts": "@taxi_owner", "is_vacancy": False, "board": None, "is_spam": False}), ] def _lead_or_404(lead_id: str) -> dict: lead = leads_svc.get_lead(lead_id) if not lead: raise HTTPException(404, "Карточка не найдена") return lead # ─── Доски ──────────────────────────────────────────────────────────────── @router.get("/boards") def boards(_: str = Depends(current_login)) -> list[dict]: return leads_svc.list_boards() @router.post("/boards") def create_board(body: BoardCreate, _: str = Depends(current_login)) -> dict: return leads_svc.create_board( name=body.name, color=body.color, keywords=body.keywords, prompt=body.prompt, description=body.description, rules=body.rules, ) @router.patch("/boards/{board_id}") def patch_board(board_id: str, body: BoardPatch, _: str = Depends(current_login)) -> dict: try: return leads_svc.patch_board(board_id, body.model_dump(exclude_none=True)) except KeyError: raise HTTPException(404, "Доска не найдена") @router.delete("/boards/{board_id}") def delete_board(board_id: str, _: str = Depends(current_login)) -> dict: moved = leads_svc.delete_board(board_id) return {"ok": True, "movedToInbox": moved} @router.post("/boards/reorder") def reorder(body: OrderBody, _: str = Depends(current_login)) -> dict: leads_svc.reorder_boards(body.order) return {"ok": True} # ─── Состояние колонок ──────────────────────────────────────────────────── @router.get("/columns/state") def columns_state(_: str = Depends(current_login)) -> dict: return leads_svc.get_col_state() @router.patch("/columns/{col_id}/state") def set_column_state(col_id: str, body: ColStateBody, _: str = Depends(current_login)) -> dict: state = leads_svc.get_col_state().get(col_id, {}) state.update(body.model_dump(exclude_none=True)) return leads_svc.set_col_state(col_id, state) # ─── Лиды ───────────────────────────────────────────────────────────────── @router.get("/leads") def list_leads(col: str | None = None, _: str = Depends(current_login)) -> dict: if col and col not in ("inbox", "archive", "trash") and not any(b["id"] == col for b in leads_svc.list_boards()): raise HTTPException(400, "Неизвестная колонка") return {"items": leads_svc.list_leads(col)} @router.get("/leads/counts") def leads_counts(_: str = Depends(current_login)) -> dict: return leads_svc.counts() @router.get("/leads/{lead_id}") def get_lead(lead_id: str, _: str = Depends(current_login)) -> dict: return _lead_or_404(lead_id) @router.post("/leads/{lead_id}/seen") def seen(lead_id: str, _: str = Depends(current_login)) -> dict: leads_svc.mark_seen(lead_id=lead_id) return {"ok": True} @router.post("/leads/mark-all-seen") def mark_all_seen(_: str = Depends(current_login)) -> dict: leads_svc.mark_seen() return {"ok": True} class MarkColBody(BaseModel): col: str @router.post("/leads/mark-col-seen") def mark_col_seen(body: MarkColBody, _: str = Depends(current_login)) -> dict: """Снять «новое» с колонки (используется при открытии колонки из сайдбара).""" leads_svc.mark_seen(col=body.col) return {"ok": True} @router.post("/leads/{lead_id}/move") def move(lead_id: str, body: MoveBody, _: str = Depends(current_login)) -> dict: try: leads_svc.move_lead(lead_id, body.to) except ValueError as exc: raise HTTPException(400, str(exc)) from exc return _lead_or_404(lead_id) @router.post("/leads/{lead_id}/trash") def trash(lead_id: str, _: str = Depends(current_login)) -> dict: _lead_or_404(lead_id) leads_svc.trash_lead(lead_id) return {"ok": True} @router.post("/leads/{lead_id}/restore") def restore(lead_id: str, _: str = Depends(current_login)) -> dict: _lead_or_404(lead_id) back = leads_svc.restore_lead(lead_id) return {"ok": True, "col": back} @router.delete("/leads/{lead_id}") def delete(lead_id: str, _: str = Depends(current_login)) -> dict: _lead_or_404(lead_id) leads_svc.delete_forever(lead_id) return {"ok": True} class ClearColBody(BaseModel): col: str @router.post("/leads/clear-col") def clear_col(body: ClearColBody, _: str = Depends(current_login)) -> dict: """Ручная полная очистка корзины/архива (безвозвратно).""" try: cleared = leads_svc.clear_col(body.col) except ValueError as exc: raise HTTPException(400, str(exc)) from exc return {"ok": True, "cleared": cleared} @router.post("/leads/{lead_id}/comments") def comment(lead_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict: if not body.text.strip(): raise HTTPException(400, "Пустой комментарий") return {"comments": leads_svc.add_comment(lead_id, body.text)} @router.post("/leads/reclassify") async def reclassify(body: ReclassifyBody | None = None, _: str = Depends(current_login)) -> dict: """Переклассификация «Неразобранного»: фоновая задача (одна за раз).""" ids = body.ids if body else None return await leads_svc.start_reclassify(ids) # ─── Поиск ──────────────────────────────────────────────────────────────── @router.get("/search") def search(q: str = "", _: str = Depends(current_login)) -> dict: return leads_svc.search(q) # ─── Админ-тик (правила хранения) ───────────────────────────────────────── @router.post("/admin/fts/rebuild") def fts_rebuild(_: str = Depends(current_login)) -> dict: ok = fts_svc.rebuild() return {"ok": ok, "ready": fts_svc.is_ready()} @router.post("/admin/check-message") async def check_message(body: CheckMessageBody, _: str = Depends(current_login)) -> dict: """Проверка фильтра входящих (этап 1 + этап 2) для тестера в настройках.""" from ..services.ai import filter_incoming r1 = stage1_plain(body.text) result = {"stage1": {"pass": r1["pass"], "reason": r1["reason"]}} if not r1["pass"]: result["stage2"] = {"pass": False, "reason": None, "skipped": True} result["passed"] = False return result try: r2 = await filter_incoming(body.text) except Exception as exc: # noqa: BLE001 r2 = {"pass": True, "reason": None, "skipped": True} result["stage2"] = r2 result["passed"] = bool(r2["pass"]) return result def _demo_off() -> None: if not config.DEMO_ENABLED: raise HTTPException(404, "Демо-режим отключён") @router.post("/demo/simulate-lead") async def simulate_lead(_: str = Depends(current_login)) -> dict: """Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1).""" _demo_off() import random text, raw = random.choice(_DEMO_POOL) digest = "demo_" + secrets.token_hex(8) lead = _store_lead( digest, "demo_channel", "Демо-канал", "demo_channel", "#8b8ff8", text, raw, time.time_ns() // 1_000_000, ) if lead: await broker.publish("new_lead", lead) await broker.publish_toast("Демо: новый лид", "sparkles") return lead or {} @router.post("/demo/age-lead") async def demo_age_lead(_: str = Depends(current_login)) -> dict: """Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1).""" _demo_off() days = int(store.get_setting("archiveAfterDays") or 14) row = store.query_one( "SELECT id FROM leads WHERE col IN (SELECT id FROM boards) ORDER BY received_at ASC LIMIT 1" ) if not row: raise HTTPException(400, "Нет карточек на досках для демо") aged = time.time_ns() // 1_000_000 - (days + 1) * C.DAY_MS store.execute("UPDATE leads SET received_at = ? WHERE id = ?", [aged, row["id"]]) stats = leads_svc.tick_storage() if stats["archived"]: await broker.publish_toast(f"Демо: карточка → Архив (старше {days + 1} дн.)", "clock") return {"ok": True, "stats": stats} @router.post("/admin/tick") async def admin_tick(_: str = Depends(current_login)) -> dict: from ..services import projects as projects_svc from ..services.pipeline import pump_once, queue_len stats = leads_svc.tick_storage() await leads_svc.notify_tick_stats(stats) due = await projects_svc.check_reminders() # разгребаем очередь входящих (этап 1 -> правила -> ML/ИИ) pump = await pump_once() return {"storage": stats, "reminders": due, "pipeline": pump, "queue": queue_len()} @router.post("/admin/wipe") async def admin_wipe(_: str = Depends(current_login)) -> dict: """Полный сброс под новый прогон: все карточки, обучение ML, счётчики ИИ/ML. Удаляет карточки со всех колонок (включая архив/корзину), исходные сообщения, журнал обучения, очередь обучения и очередь входящих; сбрасывает модель ML и счётчики реальных действий. Колонки/доски, каналы и настройки не трогаются. """ from ..services import ml_client from ..services.ml_client import DECISIONS_AI, DECISIONS_ML n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0) for tbl in ("leads", "dedup", "messages", "learning_log", "ml_outbox", "pipeline_msg", "rejected_msgs"): store.execute(f"DELETE FROM {tbl}") store.set_setting(DECISIONS_ML, 0) store.set_setting(DECISIONS_AI, 0) fts_svc.rebuild() ml = await ml_client.reset_model() return {"ok": True, "cardsRemoved": n_leads, "ml": ml} @router.post("/admin/clear-cards") async def admin_clear_cards(_: str = Depends(current_login)) -> dict: """Очистить карточки и очереди для повторного прогона, НЕ трогая ML. Удаляет карточки (все колонки/архив/корзина), исходники, дедуп, журнал обучения и очередь входящих. Модель ML, очередь её обучения (ml_outbox) и счётчики ИИ/ML остаются нетронутыми — нужно для тестов «обучить ML на прогоне с ИИ, затем прогнать те же сообщения без ИИ». """ n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0) for tbl in ("leads", "dedup", "messages", "learning_log", "pipeline_msg", "rejected_msgs"): store.execute(f"DELETE FROM {tbl}") fts_svc.rebuild() return {"ok": True, "cardsRemoved": n_leads} @router.post("/admin/pump-gate") def pump_gate(body: PumpGateBody, _: str = Depends(current_login)) -> dict: """Шлагбаум пайплайна: остановить воркер после N созданных карточек. limit=0 или None — снять ограничение (воркер продолжит разбирать очередь); limit>0 — обнулить счётчик и останавливаться после N карточек (проверка результата: «прогон с остановкой после первых N сообщений»). """ if body.limit is not None: v = max(0, int(body.limit)) store.set_setting("pumpGate", v) store.set_setting("pumpGateDone", 0) limit = int(store.get_setting("pumpGate") or 0) done = int(store.get_setting("pumpGateDone") or 0) return {"ok": True, "limit": limit, "done": done} @router.post("/ai/suggest-columns") async def ai_suggest_columns(_: str = Depends(current_login)) -> dict: """Ручной запуск анализа «Неразобранного»: ИИ предлагает колонки.""" from ..services.suggest import suggest_from_inbox result = await suggest_from_inbox(force=True) return result @router.post("/ai/suggest-keywords") async def ai_suggest_keywords(_: str = Depends(current_login)) -> dict: """ИИ предлагает общие ключевые слова-маркеры сферы по вашим карточкам.""" from ..services.suggest import suggest_domain_keywords return await suggest_domain_keywords()