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

165 lines
10 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.
"""Тест: автономный 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()