commit 61e345a9613829ac9ca812f44b26e29d721367d1 Author: bvn13 Date: Tue Oct 29 00:17:54 2024 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d1fbfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +env +env/** +.idea +.idea/** diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/README.md b/README.md new file mode 100644 index 0000000..82bda57 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# AntiSpam complex + +Комплекс ПО для борьбы со спамом + +## Компоненты + +### Диаграмма компонентов + +![c4model.png](docs/c4model.png) + +### Dataset + +- необходим первоначальный датасет (ham/spam) в формате CSV + +### Model + +- использует датасет +- модели генерируются https://pypi.org/project/spam-detector-ai/ + - naive_bayes_model.joblib + - random_forest_model.joblib + - svm_model.joblib + - logistic_regression_model.joblib + - xgb_model.joblib + +### Decision Maker + +- компонент, который принимает решения +- представлен в виде Web-сервера +- реализует API + - POST `/check-spam` - открытый для пользователей + - auth: none + - request + - body `{ "text": "SOME TEXT" } + - response + - status code 200 + - body `{ "is_spam": true }` + - POST `/update-model` - служебный, добавляет текст в датасет + - auth: TOKEN + - request + - body: `{ "text": "SOME TEXT", "is_spam": true }` + - response + - status code 200 + - body `{ "status": "OK" }` + - POST `/restart` - служебный, перезапускает данный компонент + - auth: TOKEN + - request + - body: none + - response + - status code 200 + - body `{ "status": "OK" }` + +#### Sequence diagram + +![sequence](docs/sequence.png) + +### Model Updater + +- добавляет в датасет новые тексты из базы дообучения +- запускается по графику, выполняет + - сделать бэкап модели + - запустить дообучение + - при успешном результате дообучения + - вызывает POST `/restart` компонента Decision Maker + +### Transport + +- используется [Rabbit MQ](https://rabbitmq.com/) + +## Use cases + +### Телеграм-бот + +- приходит сообщение в группу в телегу +- бот его читает (не входит в данный комплекс) +- отправляет на проверку +- получает результат: спам/не спам +- если спам - бот удаляет сообщение + +### На почте + +- та же схема, только должен быть работающий клиент почты, который читает каждое сообщение, и если обнаружился спам, то помечать его спамом (помещать в категорию спам) + +## Дообучение с учителем + +### Сбор доп.текстов через Телеграм + +- владелец бота в телеге встречает сообщение, которое является спамом, но не было удалено +- это сообщение отправляется в ТГ бот спам-определителя (не реализовано) +- сообщение помещается в базу для дообучения + +### Сбор доп.текстов через почту + +- спам-письмо можно отправить на почту, которую читает бот (не реализовано) +- бот кладет текст письма в базу дообучения + +### Дообучение + +- 1 р/сут происходит вычитывание всех сообщений из базы дообучения за день +- если в базе дообучения за день что-то есть, то + - датасет обновляется + - запускается процесс дообучения + - перезапускается модель + + + diff --git a/docs/c4model.png b/docs/c4model.png new file mode 100644 index 0000000..78edc87 Binary files /dev/null and b/docs/c4model.png differ diff --git a/docs/sequence.png b/docs/sequence.png new file mode 100644 index 0000000..8b16d49 Binary files /dev/null and b/docs/sequence.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4e324c0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.poetry] +name = "spam-detector-1" +version = "0.1.0" +description = "" +authors = ["bvn13 "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.12" +scikit-learn = "^1.5.2" +numpy = "^2.1.2" +spam-detector-ai = "^2.1.18" +nltk = "^3.9.1" +joblib = "^1.4.2" +xgboost = "^2.1.2" +imblearn = "^0.0" +requests = "^2.32.3" +googletrans = "^4.0.0rc1" +tqdm = "^4.66.5" +tornado = "^6.4.1" +asyncio = "^3.4.3" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..c3585dc --- /dev/null +++ b/src/app.py @@ -0,0 +1,27 @@ +import argparse +import os +from dataset.preparer import download_words +from model.trainer import train +import web.server +import model.updater + + +parser = argparse.ArgumentParser(prog='Antispam complex') +parser.add_argument('-i', '--init', action=argparse.BooleanOptionalAction, help='Initializing, must be run beforehand, --dataset is required') +parser.add_argument('-m', '--decision-maker', action=argparse.BooleanOptionalAction, help='Start as Decision maker') +parser.add_argument('-d', '--dataset', required=False, help='Path to CSV (ham/spam) dataset') +parser.add_argument('-u', '--model-updater', required=False, help='Start as Model updater') +args = parser.parse_args() + + +_port = 8080 if os.getenv('PORT') is None else os.getenv('PORT') + +if __name__ == '__main__': + if args.init: + assert args.dataset is not None, "Dataset is required, show --help" + download_words() + train(args.dataset) + elif args.decision_maker: + web.server.start(port=_port) + elif args.model_updater: + model.updater.start() diff --git a/src/dataset/__init__.py b/src/dataset/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dataset/preparer.py b/src/dataset/preparer.py new file mode 100644 index 0000000..f3d23f1 --- /dev/null +++ b/src/dataset/preparer.py @@ -0,0 +1,5 @@ +import nltk + +def download_words() -> None: + nltk.download('wordnet') + nltk.download('stopwords') \ No newline at end of file diff --git a/src/logging/__init__.py b/src/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logging/logger.py b/src/logging/logger.py new file mode 100644 index 0000000..9ae5088 --- /dev/null +++ b/src/logging/logger.py @@ -0,0 +1,5 @@ +import logging + +logging.basicConfig(format="%(asctime)s | %(name)s | %(levelname)s | %(message)s") +logger = logging.getLogger(__package__) +logger.setLevel(logging.INFO) diff --git a/src/model/__init__.py b/src/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/model/trainer.py b/src/model/trainer.py new file mode 100644 index 0000000..3d05c08 --- /dev/null +++ b/src/model/trainer.py @@ -0,0 +1,40 @@ +# spam_detector_ai/trainer.py +import os +import sys +from pathlib import Path +from sklearn.model_selection import train_test_split +from spam_detector_ai.classifiers.classifier_types import ClassifierType +from spam_detector_ai.training.train_models import ModelTrainer +from src.logging.logger import logger + + +def _train_model(classifier_type, model_filename, vectoriser_filename, X_train, y_train): + logger.info(f'Training {classifier_type}') + trainer_ = ModelTrainer(data=None, classifier_type=classifier_type, logger=logger) + trainer_.train(X_train, y_train) + trainer_.save_model(model_filename, vectoriser_filename) + + +def train(data_path: str) -> None: + # Load and preprocess data once + # data_path = os.path.join(project_root, 'spam.csv') + initial_trainer = ModelTrainer(data_path=data_path, logger=logger) + processed_data = initial_trainer.preprocess_data_() + + # Split the data once + X__train, _, y__train, _ = train_test_split(processed_data['processed_text'], processed_data['label'], + test_size=0.2, random_state=0) + + # Configurations for each model + configurations = [ + (ClassifierType.SVM, 'svm_model.joblib', 'svm_vectoriser.joblib'), + (ClassifierType.NAIVE_BAYES, 'naive_bayes_model.joblib', 'naive_bayes_vectoriser.joblib'), + (ClassifierType.RANDOM_FOREST, 'random_forest_model.joblib', 'random_forest_vectoriser.joblib'), + (ClassifierType.XGB, 'xgb_model.joblib', 'xgb_vectoriser.joblib'), + (ClassifierType.LOGISTIC_REGRESSION, 'logistic_regression_model.joblib', 'logistic_regression_vectoriser.joblib') + ] + + # Train each model with the pre-split data + logger.info(f"Train each model with the pre-split data\n") + for ct, mf, vf in configurations: + _train_model(ct, mf, vf, X__train, y__train) diff --git a/src/model/updater.py b/src/model/updater.py new file mode 100644 index 0000000..490f5ab --- /dev/null +++ b/src/model/updater.py @@ -0,0 +1,5 @@ +from src.logging.logger import logger + + +def start() -> None: + logger.info("Starting...") \ No newline at end of file diff --git a/src/translate-dataset.py b/src/translate-dataset.py new file mode 100644 index 0000000..e4dedb7 --- /dev/null +++ b/src/translate-dataset.py @@ -0,0 +1,75 @@ +# https://thepythoncode.com/article/translate-text-in-python +from os import close + +from googletrans import Translator +import csv +from tqdm import tqdm +import argparse +import sys +import os.path +import shutil + +csv.field_size_limit(sys.maxsize) + +parser = argparse.ArgumentParser(prog='translate-dataset') +parser.add_argument('-i', '--input', help='Source file') +parser.add_argument('-o', '--output', help='Destination file') +args = parser.parse_args() +#/home/bvn13/develop/spam-detector-1/spam.csv + +translator = Translator() + +translation = translator.translate("Hola Mundo", dest="ru") +print(f"{translation.origin} ({translation.src}) --> {translation.text} ({translation.dest})") + +total = 0 +with open(args.input, "r") as f: + reader = csv.reader(f) + for row in reader: + total += 1 +skip = 0 +bup = None +if os.path.exists(args.output): + bup = f"{args.output}.bup" + shutil.copyfile(args.output, bup) + with open(args.output, "r") as f: + reader = csv.reader(f) + for row in reader: + skip += 1 + +progress = tqdm(total=total, unit='row', unit_scale=2) +n = 0 +with open(args.input, "r") as f: + with open(args.output, "w") as tf: + bupf = None + bupcsv = None + if bup is not None: + bupf = open(bup, "r") + bupcsv = csv.reader(bupf) + next(bupcsv) + try: + reader = csv.reader(f) + progress.update(1) + ru = csv.writer(tf, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) + ru.writerow(['label', 'text']) + header = next(reader) + skipped = 1 + for row in reader: + progress.update(1) + decision = row[0] + text = row[1] + if skipped < skip: + skipped += 1 + already_translated = next(bupcsv) + ru.writerow(already_translated) + else: + try: + translated_text = translator.translate(text, dest='ru') + ru.writerow([decision] + [translated_text.text]) + except Exception as e: + print(f"Skipping line: {e}") + except Exception as e: + print(e) + finally: + if bupf is not None: + close(bupf) \ No newline at end of file diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/web/server.py b/src/web/server.py new file mode 100644 index 0000000..f12ba30 --- /dev/null +++ b/src/web/server.py @@ -0,0 +1,42 @@ +import asyncio +import json +import os +import tornado +from spam_detector_ai.prediction.predict import VotingSpamDetector +from src.logging.logger import logger + + +_spam_detector = VotingSpamDetector() + + +def _json(data) -> str: + return json.dumps(data) + +def start(port: int) -> None: + logger.info("Starting...") + + class CheckSpamHandler(tornado.web.RequestHandler): + def set_default_headers(self): + self.set_header("Access-Control-Allow-Origin", "*") + + def get(self): + body = json.loads(self.request.body) + if not 'text' in body: + self.write_error(400, body=_json({"error": "text is not specified"})) + else: + r = json.dumps({"is_spam": _spam_detector.is_spam(body['text'])}) + self.write(r) + + async def start_web_server(): + logger.info(f"Starting web server on port {port}") + app = tornado.web.Application( + [ + (r"/check-spam", CheckSpamHandler), + ], + template_path=os.path.join(os.path.dirname(__file__), "templates"), + static_path=os.path.join(os.path.dirname(__file__), "static"), + ) + app.listen(port) + await asyncio.Event().wait() + + asyncio.run(start_web_server()) \ No newline at end of file diff --git a/version b/version new file mode 100644 index 0000000..2830201 --- /dev/null +++ b/version @@ -0,0 +1 @@ +0.0.1-beta \ No newline at end of file