Files
Deal/archive/leadradar-legacy/backend/app/routers/dashboard_routes.py
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

410 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
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 6002 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()