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

Первый коммит: модульный монолит ядра (.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,57 @@
"""Временный boot-тест №2: шифрование, FTS, demo, check-message, recompute."""
import os
import sys
import tempfile
# devtests/ лежит внутри backend/ — кладём в path сам backend
tmp = tempfile.mkdtemp(prefix="leadradar_boot2_")
os.environ["LEADRADAR_DATA"] = tmp
os.environ["LEADRADAR_DEMO"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.services import rates as R # noqa: E402
def seed_lead(client):
# ручной лид через demo-эндпоинт (не требует ИИ/телеграма)
r = client.post("/api/demo/simulate-lead")
assert r.status_code == 200, r.text
return r.json()
with TestClient(app) as client:
assert client.get("/api/health").status_code == 200
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
# шифрование настроек aiConfigs (класс провайдера)
s = client.patch("/api/settings", json={"aiConfigs": {"deepseek": {"apiKey": "sk-abcdefgh1234"}}})
assert s.status_code == 200, s.text
pub = client.get("/api/settings").json()
assert pub["aiConfigs"]["deepseek"]["keySet"] is True, pub["aiConfigs"]
# FTS rebuild
f = client.post("/api/admin/fts/rebuild")
assert f.status_code == 200 and f.json()["ready"] is True, f.text
# демо-лид + поиск
lead = seed_lead(client)
assert lead.get("id"), lead
q = client.get("/api/search", params={"q": "Python"}).json()
assert isinstance(q["leads"], list)
# check-message (этап 1: стоп-фразы без ИИ)
cm = client.post("/api/admin/check-message", json={"text": "Ищу работу на неделю, вот моё резюме и портфолио"})
assert cm.status_code == 200 and cm.json()["passed"] is False and cm.json()["stage1"]["pass"] is False, cm.text
# пересчёт конверсий при смене валюты (архивные не трогаем)
R.save_rates({"RUB": 1.0, "USD": 100.0}, "mock")
client.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
refreshed = client.get("/api/settings").json()
assert refreshed["targetCurrency"] == "RUB"
tick = client.post("/api/admin/tick")
assert tick.status_code == 200
print("BOOT2 OK")
@@ -0,0 +1,73 @@
"""Read-only смоук docker-инсталляции: ничего не создаёт в живой базе.
Проверяет: health, SPA, вход, настройки/доски, поиск (ответ), admin-тик,
FTS, счётчики и связку с автономным ML-сервисом. Файловые проверки MinIO —
в opt-in режиме SMOKE_MUTATE=1 (создают проект, который надо удалять вручную).
"""
import os
import sys
import httpx
BASE = "http://localhost:8000"
MUTATE = os.getenv("SMOKE_MUTATE", "") == "1"
ok = True
def check(name, cond, extra=""):
global ok
if not cond:
ok = False
print("FAIL:", name, extra)
else:
print("ok:", name)
with httpx.Client(base_url=BASE, timeout=30) as c:
check("health", c.get("/api/health").status_code == 200)
page = c.get("/")
check("spa", page.status_code == 200 and 'id="app"' in page.text)
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
check("login", r.status_code == 200 and c.cookies.get("leadradar_session"))
boards = c.get("/api/boards").json()
check("boards api", isinstance(boards, list))
settings = c.get("/api/settings").json()
check("settings", settings.get("mlEnabled") is True and settings.get("targetCurrency") == "RUB")
# демо-механика в проде выключена
demo = c.post("/api/demo/simulate-lead")
check("demo disabled", demo.status_code == 404)
# поиск отвечает (лиды создаются только из Telegram)
q = c.get("/api/search", params={"q": "Python"}).json()
check("search", isinstance(q.get("leads"), list))
# тик (правила хранения + очередь), FTS, счётчики
tick = c.post("/api/admin/tick").json()
check("tick", "pipeline" in tick and "queue" in tick)
check("fts rebuild", c.post("/api/admin/fts/rebuild").json().get("ready") is True)
counts = c.get("/api/leads/counts").json()
check("counts", "ml" in counts and "ai" in counts and "learning" in counts)
# автономный ML-сервис (отдельный контейнер) через API основного приложения
ml = c.get("/api/ml/status").json()
check("ml reachable", ml.get("reachable") is True, ml)
pred = c.post("/api/ml/predict", json={"text": "python backend fastapi бот телеграм"}).json()
check("ml predict", isinstance(pred.get("scores"), dict))
if MUTATE:
# opt-in: проект + файл через MinIO (после проверки проект надо удалить)
card = c.post("/api/projects", json={"title": "SMOKE-TMP-удалить"}).json()
check("project", bool(card.get("id")))
r = c.post(f"/api/projects/{card['id']}/files", files=[("files", ("smoke.txt", b"docker smoke", "text/plain"))])
check("upload to MinIO", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
fid = r.json()["items"][0]["id"]
dl = c.get(f"/api/projects/{card['id']}/files/{fid}/download")
check("download from MinIO", dl.status_code == 200 and dl.content == b"docker smoke", dl.text[:100])
check("unauth me", c.get("/api/auth/me").status_code == 200) # кука жива
print("DOCKER SMOKE OK" if ok else "DOCKER SMOKE FAILED")
sys.exit(0 if ok else 1)
@@ -0,0 +1,175 @@
"""Временный e2e-тест по HTTP: реальный uvicorn + фронтовые API-вызовы."""
import os
import subprocess
import sys
import tempfile
import time
import httpx
# devtests/ лежит внутри backend/ — backend нужен как cwd для uvicorn
tmp = tempfile.mkdtemp(prefix="leadradar_e2e_")
env = dict(os.environ)
env["LEADRADAR_DATA"] = tmp
env["LEADRADAR_DEMO"] = "1"
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8077", "--log-level", "warning"],
cwd=BACKEND_DIR,
env=env,
)
BASE = "http://127.0.0.1:8077"
ok = True
def check(name, cond, extra=""):
global ok
if not cond:
ok = False
print("FAIL:", name, extra)
else:
print("ok:", name)
try:
for _ in range(40):
try:
r = httpx.get(BASE + "/api/health", timeout=1)
if r.status_code == 200:
break
except Exception:
time.sleep(0.5)
else:
raise SystemExit("server did not start")
with httpx.Client(base_url=BASE, timeout=20) as c:
check("health", c.get("/api/health").status_code == 200)
# вход
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
check("login", r.status_code == 200)
check("cookie set", bool(c.cookies.get("leadradar_session")))
me = c.get("/api/auth/me")
check("me", me.status_code == 200 and me.json().get("login") == "admin")
# стартовые данные: колонок нет по умолчанию — создаём одну через API
boards = c.get("/api/boards").json()
check("no default boards", boards == [])
bid = c.post("/api/boards", json={"name": "Python"}).json()["id"]
check("board created", bool(bid))
settings = c.get("/api/settings").json()
check("settings public", settings.get("targetCurrency") == "RUB" and settings.get("aiProvider") == "deepseek")
# демо-лид -> поиск
lead = c.post("/api/demo/simulate-lead").json()
check("demo lead", bool(lead.get("id")), str(lead)[:120])
lid = lead["id"]
q = c.get("/api/search", params={"q": "Python"}).json()
check("search", isinstance(q.get("leads"), list))
# перенос на доску
r = c.post(f"/api/leads/{lid}/move", json={"to": bid})
check("move to board", r.status_code == 200 and r.json().get("col") == bid, r.text)
r = c.post(f"/api/leads/{lid}/comments", json={"text": "Комментарий из e2e"})
check("comment", r.status_code == 200 and len(r.json().get("comments", [])) == 1)
# демо-старение -> архив
r = c.post("/api/demo/age-lead")
check("age-lead", r.status_code == 200, r.text)
leads = c.get("/api/leads").json()["items"]
arch = c.get("/api/leads", params={"col": "archive"}).json()["items"]
check("lead archived", any(l["id"] == lid for l in arch), f"leads={len(leads)}")
# восстановление
r = c.post(f"/api/leads/{lid}/restore")
check("restore", r.status_code == 200 and r.json().get("col") in ("inbox", bid), r.text)
# проекты: взять в работу
card = c.post("/api/projects/take", json={"leadId": lid}).json()
check("take to projects", bool(card.get("id")), str(card)[:120])
cid = card["id"]
leads_after = c.get("/api/leads").json()["items"]
check("lead gone from board", all(l["id"] != lid for l in leads_after))
# стадия + комментарий + ссылка + ТЗ
r = c.post(f"/api/projects/{cid}/move", json={"stage": "reply"})
check("stage reply", r.status_code == 200 and r.json().get("stage") == "reply")
r = c.post(f"/api/projects/{cid}/comments", json={"text": "Откликнулся"})
check("proj comment", r.status_code == 200)
r = c.post(f"/api/projects/{cid}/links", json={"name": "Макет", "url": "figma.com/x"})
check("proj link", r.status_code == 200 and r.json().get("links", [])[0]["url"].startswith("https://"))
r = c.patch(f"/api/projects/{cid}", json={"tzText": "ТЗ: интеграция с amoCRM"})
check("proj tz", r.status_code == 200 and r.json().get("tzText") == "ТЗ: интеграция с amoCRM")
# файл (локальный fallback без MinIO) + скачивание
r = c.post(f"/api/projects/{cid}/files", files=[("files", ("tz.pdf", b"%PDF-1.4 test", "application/pdf"))])
check("file upload", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
file_id = r.json()["items"][0]["id"]
dl = c.get(f"/api/projects/{cid}/files/{file_id}/download")
check("file download", dl.status_code == 200 and dl.content == b"%PDF-1.4 test", dl.text[:80])
r = c.delete(f"/api/projects/{cid}/files/{file_id}")
check("file remove", r.status_code == 200)
# локальная карточка
loc = c.post("/api/projects", json={"title": "", "stack": ["Go"]}).json()
check("local card", loc.get("local") is True and loc.get("title") == "")
# напоминание (hold)
r = c.post(f"/api/projects/{cid}/move", json={"stage": "hold"})
check("stage hold", r.status_code == 200)
at = int(time.time() * 1000) + 60000
r = c.post(f"/api/projects/{cid}/reminder", json={"at": at})
check("set reminder", r.status_code == 200 and r.json().get("reminder", {}).get("at") == at, r.text)
rem = c.get("/api/projects/reminders").json()["items"]
check("active reminders", any(x["id"] == cid for x in rem))
# настройки валюты и пересчёт
r = c.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
check("settings patch", r.status_code == 200)
rates = c.get("/api/rates").json()
check("rates have USD", "USD" in rates.get("rates", {}))
# mark-col-seen (новый эндпоинт)
r = c.post("/api/leads/mark-col-seen", json={"col": "inbox"})
check("mark col seen", r.status_code == 200)
# ручная полная очистка «Отклонено» (проектные карточки)
r = c.post(f"/api/projects/{cid}/move", json={"stage": "rejected"})
check("stage rejected", r.status_code == 200)
r = c.post("/api/projects/clear-rejected")
check("clear rejected", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
gone = c.get("/api/projects").json()["items"]
check("rejected gone", all(x["id"] != cid for x in gone))
# ручная полная очистка корзины (лиды дашборда)
d2 = c.post("/api/demo/simulate-lead").json()
c.post(f"/api/leads/{d2['id']}/trash")
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
check("trash has lead", any(x["id"] == d2["id"] for x in trash_items))
r = c.post("/api/leads/clear-col", json={"col": "trash"})
check("clear trash", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
check("trash empty", len(trash_items) == 0)
# архив: тот же эндпоинт (сейчас пуст — просто валидируем)
r = c.post("/api/leads/clear-col", json={"col": "archive"})
check("clear archive", r.status_code == 200)
# FTS rebuild
r = c.post("/api/admin/fts/rebuild")
check("fts rebuild", r.status_code == 200 and r.json().get("ready") is True, r.text)
# статика фронтенда из dist
page = c.get("/")
check("spa served", page.status_code == 200 and "<div id=\"app\">" in page.text)
print("E2E OK" if ok else "E2E FAILED")
finally:
server.terminate()
try:
server.wait(timeout=10)
except Exception:
server.kill()
@@ -0,0 +1,164 @@
"""Тест: автономный ML-сервис + очередь входящих (этап1 -> ML -> ИИ) + outbox.
Запускает локальный ML-сервис (mlservice/server.py) на порту 8121, учит его
напрямую по HTTP и проверяет весь контур основного приложения:
* обучение всегда идёт через outbox (даже при выключенном ML в пайплайне);
* /api/ml/predict, /api/ml/candidates, /api/ml/apply;
* очередь: stop-фраза -> удаляется; ML уверен -> карточка сразу на доску.
"""
import os
import subprocess
import sys
import tempfile
import time
import httpx
TMP = tempfile.mkdtemp(prefix="leadradar_pl_")
os.environ["LEADRADAR_DATA"] = os.path.join(TMP, "app")
os.environ["LEADRADAR_ML_URL"] = "http://127.0.0.1:8121"
os.environ["LEADRADAR_DEMO"] = "1"
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ML_DIR = os.path.join(os.path.dirname(BACKEND_DIR), "mlservice")
# ── локальный ML-сервис ───────────────────────────────────────────────────
ml_env = dict(os.environ)
ml_env["ML_DATA"] = os.path.join(TMP, "ml.duckdb")
ml_server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server:app", "--port", "8121", "--log-level", "warning"],
cwd=ML_DIR,
env=ml_env,
)
try:
for _ in range(50):
try:
if httpx.get("http://127.0.0.1:8121/health", timeout=1).status_code == 200:
break
except Exception:
time.sleep(0.3)
else:
raise SystemExit("ml service did not start")
sys.path.insert(0, BACKEND_DIR)
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.services import pipeline as pl # noqa: E402
ML = "http://127.0.0.1:8121"
SPAM_TEXTS = [
"Заработок на крипте 300 процентов в месяц гарантировано подпишись на канал",
"Инвестируй в наш фонд и получай пассивный доход каждый день без риска",
"Трейдинг бот приносит 1000 долларов в день забери свою прибыль сейчас",
"Бесплатный курс по заработку на бирже забери по ссылке внизу поста",
"Приглашаю в закрытый чат заработка пассивно без вложений начни сегодня",
"Схема быстрого заработка на арбитраже крипты без риска все проверено",
"Купи сигналы на форекс и зарабатывай миллионы пока все спят от нас",
"Пирамида дохода открыла набор новых участников успей вложиться",
]
PY_TEXTS = [
"Нужен Python разработчик для телеграм бота парсера маркетплейсов удаленно",
"Ищем middle python бекенд разработчика на fastapi для стартапа удаленка",
"Задача для python джуна написать скрипт парсинга авито с антидетектом",
"Python разработчик на django проект CRM интеграция с телеграм ботом",
"Нужен python программист для автоматизации отчетов и бота в телеграм",
"Срочно python разработчик aiogram телеграм бот для интернет магазина",
"Python backend для API на fastapi микросервисы postgres kafka",
"Разработчик python на скрапинг каталогов маркетплейсов выгрузка в excel",
"Ищем python специалиста для интеграции с мессенджерами и crm",
"Python разработчик на парсер и бота оплата достойная сразу в лс",
]
FRONT_TEXTS = [
"Frontend разработчик vuejs для корпоративного портала удаленная работа",
"Нужен верстальщик реакт для интернет магазина срочно до конца недели",
"Ищем frontend специалиста vue3 typescript компоненты дизайн система",
"Задача для фронтендера сверстать адаптивный лендинг на nuxtjs",
"Frontend разработчик react nextjs для панели администратора стартапа",
"Верстка писем и лендингов html css для маркетинговых рассылок заказ",
"Нужен vue разработчик доработка фронта для телеграм мини апп",
"Frontend инженер angular для банковского приложения гибрид офис",
]
# учим ML-сервис напрямую (как если бы фоновый воркер отправил outbox)
# — перенесено внутрь with: метки = id колонок, которые создаём через API
with TestClient(app) as client:
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
client.patch("/api/settings", json={"aiProvider": "ollama", "mlEnabled": True})
# колонок по умолчанию нет — создаём Python и Frontend
py = client.post("/api/boards", json={"name": "Python"}).json()["id"]
fr = client.post("/api/boards", json={"name": "Frontend"}).json()["id"]
for t in PY_TEXTS + FRONT_TEXTS:
httpx.post(ML + "/learn", json={"label": py if t in PY_TEXTS else fr, "text": t})
for t in SPAM_TEXTS:
httpx.post(ML + "/learn", json={"label": "spam", "text": t})
def wait_until(pred, seconds=8):
"""Фоновый воркер разбирает очередь сам — опрашиваем до наступления условия."""
deadline = time.time() + seconds
last = None
while time.time() < deadline:
last = client.post("/api/admin/tick").json()
if pred():
return last
time.sleep(0.5)
raise AssertionError("условие не наступило: %s" % pred())
def leads():
return client.get("/api/leads").json()["items"]
# статус/предсказание через основное приложение
st = client.get("/api/ml/status").json()
assert st["reachable"] and st["service"]["ready"], st
p = client.post("/api/ml/predict", json={"text": "Python backend на fastapi парсер телеграм удаленно"}).json()
assert p["take"] and p["label"] == py, p
print("ml status/predict ok")
# стоп-фраза -> удаляется из очереди, ничего не оседает
pl.enqueue("d1", "Канал А", "", "#333", 1, "Ищу работу на неделю, вот моё резюме и портфолио для отклика", 1_788_000_000_000)
assert pl.queue_len() == 1
wait_until(lambda: pl.queue_len() == 0)
assert len(leads()) == 0
print("stop-phrase drop ok")
# ML уверен -> карточка сразу на доску (без ИИ), спам -> удаление
pl.enqueue("d1", "Канал А", "", "#333", 3, "Python backend на fastapi для стартапа, бот в телеграм, удалённо", 1_788_000_200_000)
pl.enqueue("d1", "Канал А", "", "#333", 4, "Заработок на крипте инвестируй в наш фонд пассивный доход каждый день", 1_788_000_300_000)
wait_until(lambda: pl.queue_len() == 0 and any(l["col"] == py for l in leads()))
card = [l for l in leads() if l["col"] == py and l["sourceMsgId"] == 3]
assert len(card) == 1, leads()
print("ml fast-path ok")
# ручная разметка apply: spam -> карточка в корзину + обучение (outbox)
r = client.post("/api/ml/apply", json={"dialogId": "d1", "msgId": 3, "action": "spam"}).json()
assert r["learned"] is True and r["moved"] == "trash", r
assert client.get("/api/ml/status").json()["stats"]["outbox"] >= 1
# flush -> обучение уехало в ML-сервис
f = client.post("/api/ml/flush").json()
assert f["outbox"] == 0 and f["flushed"] >= 1, f
print("apply + outbox ok")
# обучение идёт всегда, даже если ML выключен в пайплайне
pl.enqueue("d1", "Канал А", "", "#333", 7, "Frontend vue разработка интерфейса компоненты верстка реакт удаленно", 1_788_000_500_000)
wait_until(lambda: any(l["col"] == fr for l in leads()))
lead = [l for l in leads() if l["col"] == fr][0]
boards = client.get("/api/boards").json()
client.patch("/api/settings", json={"mlEnabled": False})
client.post(f"/api/leads/{lead['id']}/move", json={"to": py})
assert client.get("/api/ml/status").json()["stats"]["outbox"] == 1
print("learn-always (ml off) ok")
# кандидаты канала: последние сообщения (после демо-лида их нет в TG -> fallback)
cand = client.post("/api/ml/candidates", json={"dialogId": "d1", "limit": 5}).json()
assert isinstance(cand.get("items"), list)
print("candidates ok")
print("PIPELINE TEST OK")
finally:
ml_server.terminate()
try:
ml_server.wait(timeout=10)
except Exception:
ml_server.kill()