Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,409 @@
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
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()