Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
165 lines
10 KiB
Python
165 lines
10 KiB
Python
"""Тест: автономный 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()
|