Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
@@ -1,409 +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 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()
|
||||
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user