commit 7758315632dfce71f9f1969f255763a85e5eecd3 Author: jze9 Date: Sun May 24 19:42:39 2026 +0500 feat: initial microservices project structure Services: - api: FastAPI gateway with JWT auth, async endpoints, WebSocket - worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM - worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction - worker-notifier: SMTP email notifications - worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting Infrastructure: - docker-compose.yml (production) + docker-compose.dev.yml (hot reload) - Nginx reverse proxy + WebSocket support - PostgreSQL 16 with Alembic migrations - Elasticsearch 8 with Russian/English analyzers - MinIO, RabbitMQ, Redis, Ollama Frontend: - React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5 - 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register Scripts: - Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled) Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f8aaa7e --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# PostgreSQL +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=antiplagiator +POSTGRES_USER=antiplagiator +POSTGRES_PASSWORD=changeme + +# Redis +REDIS_URL=redis://redis:6379/0 + +# RabbitMQ +RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/ + +# MinIO +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=changeme +MINIO_BUCKET_DOCS=documents +MINIO_BUCKET_BACKUPS=backups + +# Elasticsearch +ELASTICSEARCH_URL=http://elasticsearch:9200 + +# Ollama +OLLAMA_URL=http://ollama:11434 + +# JWT +SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32 +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# SMTP (Yandex) +SMTP_HOST=smtp.yandex.ru +SMTP_PORT=465 +SMTP_USER=noreply@jze9.ru +SMTP_PASSWORD=changeme +SMTP_FROM=noreply@jze9.ru + +# App +APP_URL=https://academic.jze9.ru +ENVIRONMENT=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3590f5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,98 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Environment files +*.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Node / Frontend +node_modules/ +dist/ +build/ +.npm +.eslintcache +*.tsbuildinfo + +# Docker data volumes (монтируются, не хранятся в репо) +data/ + +# FAISS индексы +*.faiss +*.index + +# Database dumps +*.sql.gz +*.dump + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Alembic +# (миграции коммитятся, только __pycache__ игнорируется) + +# MinIO / S3 кэш +.minio/ + +# ML модели (скачиваются при старте) +models/ +*.bin +*.safetensors + +# Jupyter +.ipynb_checkpoints/ +*.ipynb + +# Temp +tmp/ +temp/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d6fc2cc --- /dev/null +++ b/Makefile @@ -0,0 +1,132 @@ +.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean + +# ─── Переменные ──────────────────────────────────────────────────────────────── +COMPOSE = docker compose +COMPOSE_DEV = docker compose -f docker-compose.yml -f docker-compose.dev.yml +PROJECT = anti-plagiarism + +# ─── Разработка ──────────────────────────────────────────────────────────────── +dev: + @cp -n .env.example .env 2>/dev/null || true + $(COMPOSE_DEV) up --build + +dev-d: + @cp -n .env.example .env 2>/dev/null || true + $(COMPOSE_DEV) up --build -d + +# ─── Продакшн ───────────────────────────────────────────────────────────────── +build: + $(COMPOSE) build + +up: + $(COMPOSE) up -d + +down: + $(COMPOSE) down + +restart: + $(COMPOSE) restart + +ps: + $(COMPOSE) ps + +# ─── Миграции ───────────────────────────────────────────────────────────────── +migrate: + $(COMPOSE) exec api alembic upgrade head + +migrate-dev: + $(COMPOSE_DEV) exec api alembic upgrade head + +makemigration: + @read -p "Migration name: " name; \ + $(COMPOSE) exec api alembic revision --autogenerate -m "$$name" + +downgrade: + $(COMPOSE) exec api alembic downgrade -1 + +# ─── Логи ───────────────────────────────────────────────────────────────────── +logs: + $(COMPOSE) logs -f + +logs-api: + $(COMPOSE) logs -f api + +logs-gpu: + $(COMPOSE) logs -f worker-gpu + +logs-indexer: + $(COMPOSE) logs -f worker-indexer + +logs-notifier: + $(COMPOSE) logs -f worker-notifier + +logs-gost: + $(COMPOSE) logs -f worker-gost + +# ─── Шеллы ──────────────────────────────────────────────────────────────────── +shell-api: + $(COMPOSE) exec api bash + +shell-gpu: + $(COMPOSE) exec worker-gpu bash + +shell-indexer: + $(COMPOSE) exec worker-indexer bash + +shell-db: + $(COMPOSE) exec postgres psql -U antiplagiator antiplagiator + +shell-redis: + $(COMPOSE) exec redis redis-cli + +# ─── Линтинг и тесты ────────────────────────────────────────────────────────── +lint: + $(COMPOSE) exec api ruff check . --fix + $(COMPOSE) exec api mypy app/ + +lint-frontend: + cd services/frontend && npm run lint + +test: + $(COMPOSE) exec api pytest tests/ -v + +test-cov: + $(COMPOSE) exec api pytest tests/ -v --cov=app --cov-report=html + +# ─── Утилиты ────────────────────────────────────────────────────────────────── +clean: + $(COMPOSE) down -v --remove-orphans + docker system prune -f + +# Запустить парсер (пример: make parse PARSER=openalex ARGS="--limit 1000") +parse: + $(COMPOSE) exec api python /scripts/run_parser.py $(PARSER) $(ARGS) + +# Создать MinIO бакеты +minio-init: + $(COMPOSE) exec minio mc alias set local http://localhost:9000 minioadmin changeme + $(COMPOSE) exec minio mc mb local/documents || true + $(COMPOSE) exec minio mc mb local/backups || true + +# Создать ES индекс +es-init: + bash infra/elasticsearch/setup.sh + +# Статистика Celery +flower: + @echo "Flower доступен по адресу http://localhost:5555" + @$(COMPOSE) ps flower + +help: + @echo "Академический помощник — команды make:" + @echo "" + @echo " make dev — запустить в режиме разработки (hot reload)" + @echo " make build — собрать Docker образы" + @echo " make up — запустить в продакшн режиме" + @echo " make down — остановить все сервисы" + @echo " make migrate — применить миграции Alembic" + @echo " make logs — показать логи всех сервисов" + @echo " make shell-api — открыть shell в контейнере api" + @echo " make lint — запустить линтер" + @echo " make test — запустить тесты" + @echo " make clean — удалить все контейнеры и volumes" diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea36b12 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Академический помощник + +Микросервисная система антиплагиата и поиска академических источников. + +Студент вводит тему → система ищет источники → проверяет плагиат → форматирует ГОСТ-библиографию. +Всё асинхронно: студент закрыл браузер, получил email когда готово. + +## Архитектура + +``` + ┌─────────────────┐ + │ Frontend │ React + Vite + TypeScript + │ (React SPA) │ + └────────┬────────┘ + │ HTTPS + ┌────────▼────────┐ + │ API Gateway │ FastAPI, порт 8000 + │ (FastAPI) │ JWT, Rate Limit, WebSocket + └──┬─────────┬───┘ + │ RabbitMQ│ Celery задачи + ┌────────────▼──┐ ┌──▼──────────────┐ + │ worker-gpu │ │ worker-indexer │ + │ (CUDA/FAISS) │ │ (PDF/DOCX parse) │ + │ Sem. search │ │ Winnowing/MinHash │ + │ LLM paraphrase│ └──────────────────┘ + └────────────────┘ + │ + ┌─────────▼──────────┐ ┌────────────────────┐ + │ worker-notifier │ │ worker-gost │ + │ (SMTP email) │ │ (ГОСТ 7.1/7.0.5) │ + └────────────────────┘ └────────────────────┘ + +Инфраструктура: + PostgreSQL 16 · Redis 7 · RabbitMQ 3 · Elasticsearch 8 + MinIO (4TB) · Ollama (llama3:8b) · NVIDIA RTX 2060 +``` + +## Быстрый старт + +```bash +# 1. Клонировать репозиторий +git clone https://github.com/jze9/anti-plagiarism.git +cd anti-plagiarism + +# 2. Создать файл конфигурации +cp .env.example .env +# Отредактировать .env — сменить пароли и ключи! + +# 3. Запустить в режиме разработки +make dev + +# 4. Применить миграции базы данных +make migrate-dev + +# 5. Создать ES индекс +make es-init + +# 6. Открыть браузер +# Frontend: http://localhost:5173 +# API docs: http://localhost:8000/api/docs +# RabbitMQ: http://localhost:15672 (guest/guest) +# Flower: http://localhost:5555 +# MinIO: http://localhost:9001 +``` + +## Команды + +```bash +make dev # Запуск в dev режиме (hot reload) +make build # Сборка Docker образов +make up # Запуск в продакшн режиме +make down # Остановить все сервисы +make migrate # Применить Alembic миграции +make logs # Логи всех сервисов +make shell-api # Shell в контейнере API +make shell-gpu # Shell в контейнере GPU воркера +make lint # Запустить линтер +make test # Запустить тесты +make clean # Удалить контейнеры и volumes +``` + +## Стек технологий + +| Компонент | Технологии | +|-----------|-----------| +| API Gateway | FastAPI 0.111, Python 3.11, SQLAlchemy 2.0, Alembic | +| GPU Worker | sentence-transformers, FAISS-GPU (IVFFlat), Ollama (llama3:8b) | +| Indexer | PyMuPDF, python-docx, Winnowing, MinHash LSH | +| Очереди | RabbitMQ (брокер) + Celery 5 (воркеры) + Redis (результаты) | +| База данных | PostgreSQL 16 | +| Поиск | Elasticsearch 8 (BM25) + FAISS GPU (cosine) | +| Хранилище | MinIO (S3-совместимый) | +| Frontend | React 18, Vite, TypeScript, TailwindCSS, Zustand, React Query v5 | + +## Проверка плагиата (4 уровня) + +1. **Winnowing + MinHash** (~мс) — точное совпадение по fingerprints +2. **n-граммы + Jaccard** (~сек) — нечёткое совпадение +3. **FAISS GPU cosine** (~мс) — семантическое сходство (порог 0.75) +4. **Ollama Llama3** (~2сек) — LLM анализ парафраза (confidence > 0.7) + +## Тарифные планы + +| Тариф | Цена | Поиск/день | Изложений/мес | Плагиат/мес | Одновременно | +|-------|------|-----------|--------------|-------------|-------------| +| Бесплатный | 0₽ | 10 | 3 | 1 | 1 | +| Студенческий | 199₽ | безлимит | 30 | 10 | 2 | +| Премиум | 499₽ | безлимит | безлимит | 50 | 5 | +| Научный | 999₽ | безлимит | безлимит | безлимит | 10 | + +## Парсеры источников + +```bash +# Запарсить OpenAlex +python scripts/run_parser.py openalex \ + --query "машинное обучение" \ + --limit 10000 \ + --output /data/processed + +# КиберЛенинка +python scripts/run_parser.py cyberleninka \ + --query "нейронные сети" \ + --limit 1000 + +# arXiv +python scripts/run_parser.py arxiv \ + --query "deep learning" \ + --categories cs.AI cs.LG \ + --limit 5000 +``` + +## Переменные окружения + +Смотри `.env.example` для полного списка переменных. +Обязательно смените `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. + +## Продакшн деплой + +```bash +# На сервере +cp .env.example .env +nano .env # Настроить все пароли и ключи + +make build +make up +make migrate + +# SSL сертификат (Let's Encrypt) +certbot --nginx -d academic.jze9.ru +``` + +## Лицензия + +MIT diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..323a029 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,63 @@ +version: "3.9" + +# ═══════════════════════════════════════════════════════════════════════════════ +# Override для разработки: hot reload, volume mounts, DEBUG режим +# Использование: docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# ═══════════════════════════════════════════════════════════════════════════════ + +services: + + api: + build: + context: ./services/api + dockerfile: Dockerfile + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app + volumes: + - ./services/api:/app + environment: + DEBUG: "true" + ENVIRONMENT: development + + worker-gpu: + build: + context: ./services/worker-gpu + dockerfile: Dockerfile + command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=debug + volumes: + - ./services/worker-gpu:/app + environment: + DEBUG: "true" + ENVIRONMENT: development + + worker-indexer: + build: + context: ./services/worker-indexer + dockerfile: Dockerfile + command: celery -A app.celery_app worker -Q queue.index -c 2 -n indexer@%h --loglevel=debug + volumes: + - ./services/worker-indexer:/app + environment: + DEBUG: "true" + ENVIRONMENT: development + + worker-notifier: + build: + context: ./services/worker-notifier + dockerfile: Dockerfile + command: celery -A app.celery_app worker -Q queue.notify -c 2 -n notifier@%h --loglevel=debug + volumes: + - ./services/worker-notifier:/app + environment: + DEBUG: "true" + ENVIRONMENT: development + + worker-gost: + build: + context: ./services/worker-gost + dockerfile: Dockerfile + command: celery -A app.celery_app worker -Q queue.gost -c 2 -n gost@%h --loglevel=debug + volumes: + - ./services/worker-gost:/app + environment: + DEBUG: "true" + ENVIRONMENT: development diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..51fdd06 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,259 @@ +version: "3.9" + +# ═══════════════════════════════════════════════════════════════════════════════ +# Академический помощник — Production Docker Compose +# ═══════════════════════════════════════════════════════════════════════════════ + +networks: + antiplagiator: + driver: bridge + +volumes: + postgres_data: + redis_data: + rabbitmq_data: + elasticsearch_data: + minio_data: + ollama_data: + faiss_index: + +# ─── Общие переменные окружения для app-сервисов ──────────────────────────── +x-app-env: &app-env + env_file: .env + networks: + - antiplagiator + restart: unless-stopped + +services: + + # ═══════════════════════════════════════════════════════════════════════════ + # ИНФРАСТРУКТУРА + # ═══════════════════════════════════════════════════════════════════════════ + + postgres: + image: postgres:16 + container_name: antiplagiator-postgres + environment: + POSTGRES_DB: antiplagiator + POSTGRES_USER: antiplagiator + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + volumes: + - ./data/postgres:/var/lib/postgresql/data + - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - antiplagiator + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U antiplagiator -d antiplagiator"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: antiplagiator-redis + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - ./data/redis:/data + networks: + - antiplagiator + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + rabbitmq: + image: rabbitmq:3-management + container_name: antiplagiator-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest} + volumes: + - ./data/rabbitmq:/var/lib/rabbitmq + networks: + - antiplagiator + ports: + - "15672:15672" # Management UI + restart: unless-stopped + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + elasticsearch: + image: elasticsearch:8.13.0 + container_name: antiplagiator-elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.ml.enabled=false + - ES_JAVA_OPTS=-Xms2g -Xmx2g + - cluster.name=antiplagiator + - bootstrap.memory_lock=true + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - ./data/elasticsearch:/usr/share/elasticsearch/data + networks: + - antiplagiator + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -qv '\"status\":\"red\"'"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + minio: + image: minio/minio:latest + container_name: antiplagiator-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-changeme} + volumes: + - ./data/minio:/data + networks: + - antiplagiator + ports: + - "9001:9001" # Console UI + restart: unless-stopped + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 30s + timeout: 10s + retries: 5 + + ollama: + image: ollama/ollama:latest + container_name: antiplagiator-ollama + volumes: + - ./data/ollama:/root/.ollama + networks: + - antiplagiator + restart: unless-stopped + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + # ═══════════════════════════════════════════════════════════════════════════ + # ПРИЛОЖЕНИЕ + # ═══════════════════════════════════════════════════════════════════════════ + + api: + build: + context: ./services/api + dockerfile: Dockerfile + container_name: antiplagiator-api + <<: *app-env + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + elasticsearch: + condition: service_healthy + minio: + condition: service_healthy + + worker-gpu: + build: + context: ./services/worker-gpu + dockerfile: Dockerfile + container_name: antiplagiator-worker-gpu + <<: *app-env + command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info + volumes: + - faiss_index:/data/index + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + worker-indexer: + build: + context: ./services/worker-indexer + dockerfile: Dockerfile + container_name: antiplagiator-worker-indexer + <<: *app-env + command: celery -A app.celery_app worker -Q queue.index -c 4 -n indexer@%h --loglevel=info + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + elasticsearch: + condition: service_healthy + + worker-notifier: + build: + context: ./services/worker-notifier + dockerfile: Dockerfile + container_name: antiplagiator-worker-notifier + <<: *app-env + command: celery -A app.celery_app worker -Q queue.notify -c 16 -n notifier@%h --loglevel=info + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + + worker-gost: + build: + context: ./services/worker-gost + dockerfile: Dockerfile + container_name: antiplagiator-worker-gost + <<: *app-env + command: celery -A app.celery_app worker -Q queue.gost -c 8 -n gost@%h --loglevel=info + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + + flower: + image: mher/flower:2.0 + container_name: antiplagiator-flower + command: celery --broker=${RABBITMQ_URL:-amqp://guest:guest@rabbitmq:5672/} flower --port=5555 + networks: + - antiplagiator + ports: + - "5555:5555" + depends_on: + - rabbitmq + - redis + restart: unless-stopped + environment: + CELERY_BROKER_URL: ${RABBITMQ_URL:-amqp://guest:guest@rabbitmq:5672/} + CELERY_RESULT_BACKEND: ${REDIS_URL:-redis://redis:6379/0} diff --git a/infra/elasticsearch/mappings.json b/infra/elasticsearch/mappings.json new file mode 100644 index 0000000..c7ae5a9 --- /dev/null +++ b/infra/elasticsearch/mappings.json @@ -0,0 +1,75 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "analysis": { + "analyzer": { + "russian_analyzer": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase", "russian_stop", "russian_stemmer"] + }, + "english_analyzer": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase", "english_stop", "english_stemmer"] + }, + "multilingual_analyzer": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase", "russian_stop", "russian_stemmer", "english_stop"] + } + }, + "filter": { + "russian_stop": { + "type": "stop", + "stopwords": "_russian_" + }, + "russian_stemmer": { + "type": "stemmer", + "language": "russian" + }, + "english_stop": { + "type": "stop", + "stopwords": "_english_" + }, + "english_stemmer": { + "type": "stemmer", + "language": "english" + } + } + } + }, + "mappings": { + "properties": { + "doc_id": {"type": "long"}, + "source": {"type": "keyword"}, + "title": { + "type": "text", + "analyzer": "multilingual_analyzer", + "fields": { + "keyword": {"type": "keyword", "ignore_above": 512}, + "ru": {"type": "text", "analyzer": "russian_analyzer"}, + "en": {"type": "text", "analyzer": "english_analyzer"} + } + }, + "abstract": { + "type": "text", + "analyzer": "multilingual_analyzer", + "fields": { + "ru": {"type": "text", "analyzer": "russian_analyzer"}, + "en": {"type": "text", "analyzer": "english_analyzer"} + } + }, + "authors": { + "type": "text", + "analyzer": "multilingual_analyzer" + }, + "year": {"type": "integer"}, + "lang": {"type": "keyword"}, + "journal": {"type": "keyword", "ignore_above": 512}, + "doi": {"type": "keyword"}, + "url": {"type": "keyword", "index": false} + } + } +} diff --git a/infra/elasticsearch/setup.sh b/infra/elasticsearch/setup.sh new file mode 100755 index 0000000..c497695 --- /dev/null +++ b/infra/elasticsearch/setup.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Создание ES индекса с маппингом для документов +# Запускать после старта контейнера Elasticsearch + +set -e + +ES_URL="${ELASTICSEARCH_URL:-http://localhost:9200}" +INDEX_NAME="documents" +MAPPINGS_FILE="$(dirname "$0")/mappings.json" + +echo "Ожидание готовности Elasticsearch..." +until curl -s "$ES_URL/_cluster/health" | grep -qv '"status":"red"'; do + sleep 2 +done +echo "Elasticsearch готов." + +# Проверить существование индекса +if curl -s -o /dev/null -w "%{http_code}" "$ES_URL/$INDEX_NAME" | grep -q "200"; then + echo "Индекс '$INDEX_NAME' уже существует." + read -p "Пересоздать? (y/N): " confirm + if [[ $confirm != "y" ]]; then + echo "Пропускаем." + exit 0 + fi + echo "Удаление старого индекса..." + curl -s -X DELETE "$ES_URL/$INDEX_NAME" + echo "" +fi + +echo "Создание индекса '$INDEX_NAME'..." +curl -s -X PUT "$ES_URL/$INDEX_NAME" \ + -H "Content-Type: application/json" \ + -d @"$MAPPINGS_FILE" | python3 -m json.tool + +echo "" +echo "Индекс '$INDEX_NAME' создан." diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf new file mode 100644 index 0000000..906e216 --- /dev/null +++ b/infra/nginx/nginx.conf @@ -0,0 +1,113 @@ +# Nginx конфиг для academic.jze9.ru +# Продакшн: SSL + proxy to FastAPI + React static + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Логи + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + gzip_min_length 1024; + gzip_vary on; + + # Лимиты + client_max_body_size 100M; + proxy_read_timeout 300s; + proxy_connect_timeout 30s; + proxy_send_timeout 300s; + + # Upstream + upstream api { + server api:8000; + keepalive 32; + } + + # HTTP → HTTPS редирект + server { + listen 80; + server_name academic.jze9.ru; + return 301 https://$server_name$request_uri; + } + + # HTTPS основной сервер + server { + listen 443 ssl http2; + server_name academic.jze9.ru; + + # SSL (Let's Encrypt) + ssl_certificate /etc/letsencrypt/live/academic.jze9.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/academic.jze9.ru/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + # HSTS + add_header Strict-Transport-Security "max-age=63072000" always; + + # Security headers + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + add_header X-XSS-Protection "1; mode=block"; + + # ── API proxy ────────────────────────────────────────────────────────── + location /api/ { + proxy_pass http://api/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + } + + # ── WebSocket ────────────────────────────────────────────────────────── + location /ws/ { + proxy_pass http://api/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; + } + + # ── React SPA (статика) ──────────────────────────────────────────────── + root /var/www/academic; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + expires 1d; + add_header Cache-Control "public, no-transform"; + } + + # Кэшировать статические ресурсы + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|woff)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + } +} diff --git a/infra/postgres/init.sql b/infra/postgres/init.sql new file mode 100644 index 0000000..dd6f98c --- /dev/null +++ b/infra/postgres/init.sql @@ -0,0 +1,14 @@ +-- Инициализация PostgreSQL для Академического помощника +-- Выполняется при первом старте контейнера + +-- Расширения +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Для нечёткого поиска по тексту + +-- Настройки подключения (уже создан через POSTGRES_USER/POSTGRES_DB env) +-- Дополнительные гранты +GRANT ALL PRIVILEGES ON DATABASE antiplagiator TO antiplagiator; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO antiplagiator; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO antiplagiator; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO antiplagiator; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO antiplagiator; diff --git a/scripts/parsers/arxiv.py b/scripts/parsers/arxiv.py new file mode 100644 index 0000000..c82f9f9 --- /dev/null +++ b/scripts/parsers/arxiv.py @@ -0,0 +1,218 @@ +"""Парсер arXiv API. + +arXiv — открытый препринт-сервер в физике, математике, CS и биологии. +API: https://info.arxiv.org/help/api/index.html + +Использует Atom XML API. +""" + +import time +import logging +import xml.etree.ElementTree as ET +from typing import Any + +import httpx + +from base import BaseParser + +logger = logging.getLogger(__name__) + +ARXIV_API = "https://export.arxiv.org/api/query" +RATE_LIMIT_DELAY = 3.0 # arXiv требует не более 1 запроса/3сек + +# Namespace XML +NS = { + "atom": "http://www.w3.org/2005/Atom", + "arxiv": "http://arxiv.org/schemas/atom", + "dc": "http://purl.org/dc/elements/1.1/", + "opensearch": "http://a9.com/-/spec/opensearch/1.1/", +} + + +class ArxivParser(BaseParser): + """Парсер arXiv API.""" + + source_name = "arxiv" + + def __init__(self) -> None: + super().__init__() + self.client = httpx.Client( + headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"}, + timeout=30.0, + ) + + def fetch( + self, + query: str = "", + limit: int = 500, + categories: list[str] | None = None, + year_from: int | None = None, + ) -> list[dict[str, Any]]: + """ + Получить препринты из arXiv. + + Args: + query: Поисковый запрос + limit: Максимальное количество документов + categories: Список категорий arXiv (cs.AI, math.ST и т.д.) + year_from: Год публикации от + + Returns: + Список сырых словарей + """ + results = [] + start = 0 + max_results = min(100, limit) + + # Формируем запрос + search_query = "" + if query: + search_query = f"all:{query}" + if categories: + cat_query = " OR ".join(f"cat:{c}" for c in categories) + search_query = f"({search_query}) AND ({cat_query})" if search_query else f"({cat_query})" + + if not search_query: + search_query = "all:neural network" # Default query + + while len(results) < limit: + try: + params = { + "search_query": search_query, + "start": start, + "max_results": min(max_results, limit - len(results)), + "sortBy": "submittedDate", + "sortOrder": "descending", + } + + response = self.client.get(ARXIV_API, params=params) + response.raise_for_status() + + entries = self._parse_xml(response.text) + if not entries: + break + + results.extend(entries) + start += len(entries) + + if len(entries) < max_results: + break + + time.sleep(RATE_LIMIT_DELAY) + + except httpx.HTTPStatusError as e: + logger.error(f"arXiv HTTP ошибка: {e.response.status_code}") + break + except Exception as e: + logger.error(f"Ошибка запроса arXiv: {e}") + break + + return results[:limit] + + def _parse_xml(self, xml_text: str) -> list[dict[str, Any]]: + """Парсить Atom XML ответ arXiv.""" + try: + root = ET.fromstring(xml_text) + except ET.ParseError as e: + logger.error(f"Ошибка парсинга XML arXiv: {e}") + return [] + + entries = [] + for entry in root.findall("atom:entry", NS): + entries.append(self._parse_entry(entry)) + + return entries + + def _parse_entry(self, entry: ET.Element) -> dict[str, Any]: + """Парсить один entry из Atom XML.""" + def text(path: str) -> str | None: + elem = entry.find(path, NS) + return elem.text.strip() if elem is not None and elem.text else None + + arxiv_id = text("atom:id") or "" + # Нормализовать: убрать версию + if "abs/" in arxiv_id: + arxiv_id = arxiv_id.split("abs/")[-1].split("v")[0] + + # Авторы + authors = [] + for author_elem in entry.findall("atom:author", NS): + name = text("atom:name") if author_elem.find("atom:name", NS) is not None else None + if name: + parts = name.strip().split() + if len(parts) >= 2: + authors.append({ + "last_name": parts[-1], + "first_name": " ".join(parts[:-1]), + }) + elif parts: + authors.append({"last_name": parts[0], "first_name": ""}) + + # Год из published + published = text("atom:published") or "" + year = int(published[:4]) if published[:4].isdigit() else None + + # DOI + doi = None + for link in entry.findall("atom:link", NS): + if link.get("title") == "doi": + doi = link.get("href", "").replace("http://dx.doi.org/", "") + break + + # URL + url = None + for link in entry.findall("atom:link", NS): + if link.get("type") == "text/html": + url = link.get("href") + break + + # Категории + categories = [ + cat.get("term", "") + for cat in entry.findall("atom:category", NS) + ] + + return { + "id": arxiv_id, + "title": text("atom:title") or "", + "abstract": text("atom:summary") or "", + "authors": authors, + "year": year, + "published": published, + "doi": doi, + "url": url, + "categories": categories, + "journal": "arXiv", + } + + def transform(self, raw: dict[str, Any]) -> dict[str, Any]: + """Преобразовать документ arXiv в унифицированный формат.""" + ext_id = raw.get("id", "") + if not ext_id: + return {} + + # Нормализовать авторов + authors = self.normalize_authors(raw.get("authors", [])) + + # Определить язык (arXiv — преимущественно английский) + lang = "en" + + # Категории как JSON + categories = raw.get("categories", []) + + return { + "source": self.source_name, + "ext_id": f"arxiv:{ext_id}", + "doi": raw.get("doi") or None, + "title": (raw.get("title") or "").replace("\n", " ").strip() or None, + "authors": authors, + "year": raw.get("year"), + "lang": lang, + "journal": "arXiv", + "volume": None, + "issue": None, + "pages": None, + "abstract": (raw.get("abstract") or "").replace("\n", " ").strip() or None, + "url": raw.get("url"), + "full_text": None, + } diff --git a/scripts/parsers/base.py b/scripts/parsers/base.py new file mode 100644 index 0000000..8e8d22c --- /dev/null +++ b/scripts/parsers/base.py @@ -0,0 +1,166 @@ +"""Базовый класс для всех парсеров источников. + +Определяет унифицированный интерфейс и формат документов. +""" + +import json +import logging +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +# Унифицированный формат документа +UNIFIED_SCHEMA = { + "source": str, # openalex, cyberleninka, arxiv, wikipedia_ru, wikipedia_en + "ext_id": str, # Внешний ID (уникален в рамках источника) + "doi": "str|None", + "title": str, + "authors": list, # [{"last_name": str, "first_name": str, "initials": str}] + "year": "int|None", + "lang": "str|None", # ru, en + "journal": "str|None", + "volume": "str|None", + "issue": "str|None", + "pages": "str|None", + "abstract": "str|None", + "url": "str|None", + "full_text": "str|None", # Для fingerprinting (не хранится в PostgreSQL) +} + + +class BaseParser(ABC): + """Базовый класс парсера источника.""" + + source_name: str = "unknown" + + def __init__(self) -> None: + self.logger = logging.getLogger(f"parser.{self.source_name}") + + @abstractmethod + def fetch(self, **kwargs) -> list[dict[str, Any]]: + """ + Получить сырые документы из источника. + + Args: + **kwargs: Специфичные для источника параметры (query, limit и т.д.) + + Returns: + Список сырых словарей из API источника + """ + ... + + def transform(self, raw: dict[str, Any]) -> dict[str, Any]: + """ + Привести сырой документ к унифицированному формату. + + Args: + raw: Сырой словарь из API источника + + Returns: + Документ в унифицированном формате + """ + raise NotImplementedError( + f"Парсер {self.source_name!r} должен реализовать метод transform()" + ) + + def save_jsonl(self, docs: list[dict[str, Any]], output_path: Path) -> None: + """ + Сохранить документы в JSONL формате (один JSON объект на строку). + + Args: + docs: Список документов + output_path: Путь к выходному файлу (дополняется, не перезаписывается) + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("a", encoding="utf-8") as f: + for doc in docs: + f.write(json.dumps(doc, ensure_ascii=False) + "\n") + + def run(self, output_dir: Path, **kwargs) -> list[dict[str, Any]]: + """ + Запустить парсер: fetch → transform → save. + + Args: + output_dir: Директория для сохранения результатов + **kwargs: Параметры для метода fetch() + + Returns: + Список трансформированных документов + """ + self.logger.info(f"[{self.source_name}] Начало парсинга...") + + raw_docs = self.fetch(**kwargs) + self.logger.info(f"[{self.source_name}] Получено {len(raw_docs)} документов") + + transformed = [] + errors = 0 + for raw in raw_docs: + try: + doc = self.transform(raw) + if doc and doc.get("title") and doc.get("ext_id"): + transformed.append(doc) + except Exception as e: + errors += 1 + self.logger.warning(f"Ошибка трансформации: {e}") + + if errors: + self.logger.warning(f"[{self.source_name}] Ошибок трансформации: {errors}") + + out_file = output_dir / f"{self.source_name}.jsonl" + self.save_jsonl(transformed, out_file) + + self.logger.info( + f"[{self.source_name}] Сохранено {len(transformed)} документов → {out_file}" + ) + + return transformed + + @staticmethod + def normalize_authors(raw_authors: list) -> list[dict[str, str]]: + """ + Нормализовать список авторов к единому формату. + + Args: + raw_authors: Авторы из API (разный формат у каждого источника) + + Returns: + Список {"last_name": str, "first_name": str, "initials": str} + """ + result = [] + for author in raw_authors: + if isinstance(author, str): + parts = author.strip().split() + last_name = parts[0] if parts else "" + first_name = " ".join(parts[1:]) if len(parts) > 1 else "" + elif isinstance(author, dict): + last_name = ( + author.get("last_name") or + author.get("family") or + author.get("surname") or "" + ).strip() + first_name = ( + author.get("first_name") or + author.get("given") or "" + ).strip() + else: + continue + + if not last_name: + continue + + # Инициалы + initials = "" + if first_name: + parts = first_name.split() + initials = "".join(p[0].upper() + "." for p in parts if p) + + result.append({ + "last_name": last_name, + "first_name": first_name, + "initials": initials, + }) + + return result diff --git a/scripts/parsers/cyberleninka.py b/scripts/parsers/cyberleninka.py new file mode 100644 index 0000000..3a3dd6e --- /dev/null +++ b/scripts/parsers/cyberleninka.py @@ -0,0 +1,233 @@ +"""Парсер КиберЛенинки. + +КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей. +Сайт: https://cyberleninka.ru + +Используем парсинг HTML страниц со строгим rate limiting (1 req/sec), +а не недокументированное API. +""" + +import re +import time +import logging +from typing import Any + +import httpx +from bs4 import BeautifulSoup + +from base import BaseParser + +logger = logging.getLogger(__name__) + +BASE_URL = "https://cyberleninka.ru" +SEARCH_URL = f"{BASE_URL}/api/search" +ARTICLE_URL = f"{BASE_URL}/article" +RATE_LIMIT_DELAY = 1.0 # секунд между запросами + + +class CyberLeninkaParser(BaseParser): + """Парсер КиберЛенинки через HTML + API поиска.""" + + source_name = "cyberleninka" + + def __init__(self) -> None: + super().__init__() + self.client = httpx.Client( + headers={ + "User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)", + "Accept-Language": "ru-RU,ru;q=0.9", + }, + timeout=30.0, + follow_redirects=True, + ) + + def fetch( + self, + query: str = "", + limit: int = 500, + subject: str | None = None, + ) -> list[dict[str, Any]]: + """ + Получить статьи из КиберЛенинки. + + Args: + query: Поисковый запрос + limit: Максимальное количество статей + subject: Предметная область (опционально) + + Returns: + Список сырых словарей статей + """ + results = [] + page = 0 + + while len(results) < limit: + try: + params: dict[str, Any] = { + "q": query, + "size": min(10, limit - len(results)), + "from": page * 10, + } + + response = self.client.get(SEARCH_URL, params=params) + response.raise_for_status() + + data = response.json() + items = data.get("articles") or [] + + if not items: + break + + results.extend(items) + page += 1 + + if len(items) < 10: + break + + time.sleep(RATE_LIMIT_DELAY) + + except httpx.HTTPStatusError as e: + logger.error(f"КиберЛенинка HTTP ошибка: {e.response.status_code}") + break + except Exception as e: + logger.error(f"Ошибка запроса КиберЛенинки: {e}") + break + + return results[:limit] + + def transform(self, raw: dict[str, Any]) -> dict[str, Any]: + """ + Преобразовать статью КиберЛенинки в унифицированный формат. + + Структура ответа API поиска КиберЛенинки: + - id: числовой ID + - name: название + - authors: строка с авторами + - journal: название журнала + - year: год + - annotation: аннотация + - link: путь к статье + """ + raw_id = raw.get("id") or raw.get("link", "") + ext_id = str(raw_id) + + if not ext_id: + return {} + + # Авторы (строка вида "Иванов И.И., Петров П.П.") + authors_str = raw.get("authors", "") or "" + authors = _parse_cyberleninka_authors(authors_str) + + # Год + year_raw = raw.get("year") + year = None + if year_raw: + try: + year = int(str(year_raw)[:4]) + except (ValueError, TypeError): + pass + + # URL + link = raw.get("link", "") + url = f"{BASE_URL}{link}" if link.startswith("/") else link or None + + return { + "source": self.source_name, + "ext_id": ext_id, + "doi": raw.get("doi") or None, + "title": (raw.get("name") or "").strip() or None, + "authors": authors, + "year": year, + "lang": "ru", # КиберЛенинка — только русскоязычные + "journal": raw.get("journal") or None, + "volume": raw.get("volume") or None, + "issue": raw.get("number") or None, + "pages": raw.get("pages") or None, + "abstract": (raw.get("annotation") or "").strip() or None, + "url": url, + "full_text": None, + } + + def fetch_article_details(self, url: str) -> dict[str, Any]: + """ + Получить детали статьи из HTML страницы. + + Извлекает метатеги citation_* по Dublin Core / Google Scholar стандарту. + + Args: + url: URL страницы статьи + + Returns: + Словарь с дополнительными метаданными + """ + try: + time.sleep(RATE_LIMIT_DELAY) + response = self.client.get(url) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + meta = {} + + # Извлечь citation_* мета теги + for tag in soup.find_all("meta"): + name = tag.get("name", "") + content = tag.get("content", "") + if name.startswith("citation_") and content: + key = name[9:] # Убрать "citation_" + meta[key] = content + + return { + "doi": meta.get("doi"), + "title": meta.get("title"), + "abstract": meta.get("abstract"), + "year": meta.get("publication_date", "")[:4] or None, + "journal": meta.get("journal_title"), + "volume": meta.get("volume"), + "issue": meta.get("issue"), + "pages": f"{meta.get('firstpage', '')}-{meta.get('lastpage', '')}".strip("-") or None, + } + except Exception as e: + logger.warning(f"Ошибка получения деталей статьи {url}: {e}") + return {} + + +def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]: + """ + Разбить строку авторов КиберЛенинки на список. + + Форматы: "Иванов И.И.", "Иванов И.И., Петров П.П." + """ + if not authors_str.strip(): + return [] + + results = [] + # Разделитель — запятая, но не внутри инициалов + parts = re.split(r",\s+(?=[А-ЯA-Z])", authors_str.strip()) + + for part in parts: + part = part.strip().rstrip(",") + words = part.split() + + if not words: + continue + + # Ищем паттерн: Фамилия Инициалы (последнее слово — инициалы) + if len(words) >= 2: + # Проверить, последнее ли слово — инициалы (содержит точки) + if "." in words[-1]: + last_name = " ".join(words[:-1]) + initials = words[-1] + else: + last_name = words[0] + initials = "".join(w[0].upper() + "." for w in words[1:] if w) + else: + last_name = words[0] + initials = "" + + results.append({ + "last_name": last_name, + "first_name": "", + "initials": initials, + }) + + return results diff --git a/scripts/parsers/openalex.py b/scripts/parsers/openalex.py new file mode 100644 index 0000000..8110797 --- /dev/null +++ b/scripts/parsers/openalex.py @@ -0,0 +1,245 @@ +"""Парсер OpenAlex API. + +OpenAlex — открытая академическая база данных с >250 млн работ. +API: https://docs.openalex.org/ + +Особенности: +- Cursor-based пагинация (не offset, чтобы не было дублей) +- Rate limit: 100,000 запросов/день без ключа +- Идемпотентность: проверка по ext_id перед добавлением +""" + +import time +import logging +from typing import Any, Generator + +import httpx + +from base import BaseParser + +logger = logging.getLogger(__name__) + +OPENALEX_API = "https://api.openalex.org" +DEFAULT_EMAIL = "noreply@jze9.ru" # Для вежливого агента + + +class OpenAlexParser(BaseParser): + """Парсер OpenAlex API с cursor-based пагинацией.""" + + source_name = "openalex" + + def __init__(self, email: str = DEFAULT_EMAIL) -> None: + super().__init__() + self.email = email + self.client = httpx.Client( + headers={"User-Agent": f"AcademicHelper/1.0 ({email})"}, + timeout=30.0, + ) + + def fetch( + self, + query: str = "", + limit: int = 1000, + lang: str | None = None, + year_from: int | None = None, + year_to: int | None = None, + type_filter: str = "journal-article", + ) -> list[dict[str, Any]]: + """ + Получить документы из OpenAlex. + + Args: + query: Поисковый запрос + limit: Максимальное количество документов + lang: Язык ('ru', 'en', None = все) + year_from: Год публикации от + year_to: Год публикации до + type_filter: Тип документа (journal-article, book, и т.д.) + + Returns: + Список сырых словарей из OpenAlex API + """ + results = [] + + for page in self._paginate( + query=query, + limit=limit, + lang=lang, + year_from=year_from, + year_to=year_to, + type_filter=type_filter, + ): + results.extend(page) + if len(results) >= limit: + break + + return results[:limit] + + def _paginate( + self, + query: str, + limit: int, + lang: str | None, + year_from: int | None, + year_to: int | None, + type_filter: str, + ) -> Generator[list[dict], None, None]: + """Cursor-based пагинация OpenAlex.""" + cursor = "*" + per_page = min(200, limit) + total_fetched = 0 + + while total_fetched < limit: + params: dict[str, Any] = { + "per-page": per_page, + "cursor": cursor, + "mailto": self.email, + } + + # Фильтры + filters = [f"type:{type_filter}", "is_oa:true"] + + if query: + params["search"] = query + + if lang: + filters.append(f"language:{lang}") + + if year_from and year_to: + filters.append(f"publication_year:{year_from}-{year_to}") + elif year_from: + filters.append(f"publication_year:>{year_from}") + elif year_to: + filters.append(f"publication_year:<{year_to}") + + params["filter"] = ",".join(filters) + + try: + response = self.client.get(f"{OPENALEX_API}/works", params=params) + response.raise_for_status() + data = response.json() + + works = data.get("results", []) + if not works: + break + + yield works + total_fetched += len(works) + + # Следующий курсор + cursor = data.get("meta", {}).get("next_cursor") + if not cursor: + break + + # Rate limiting: 10 запросов/сек без ключа + time.sleep(0.1) + + except httpx.HTTPStatusError as e: + logger.error(f"OpenAlex HTTP ошибка: {e.response.status_code}") + if e.response.status_code == 429: + logger.warning("Rate limit! Ожидаем 60 секунд...") + time.sleep(60) + continue + break + except Exception as e: + logger.error(f"Ошибка запроса OpenAlex: {e}") + break + + def transform(self, raw: dict[str, Any]) -> dict[str, Any]: + """ + Преобразовать документ OpenAlex в унифицированный формат. + + OpenAlex структура: + - id: строка вида "https://openalex.org/W..." + - doi: DOI ссылка + - title: название + - authorships: список авторов + - publication_year: год + - primary_location.source.display_name: журнал + - open_access.oa_url: ссылка на PDF + - abstract_inverted_index: инвертированный индекс аннотации + """ + # Нормализовать ext_id (убрать URL-часть) + raw_id = raw.get("id", "") + ext_id = raw_id.replace("https://openalex.org/", "") if raw_id else "" + + if not ext_id: + return {} + + # Авторы + authorships = raw.get("authorships", []) + authors_raw = [] + for a in authorships[:10]: # Максимум 10 авторов + author = a.get("author", {}) + display_name = author.get("display_name", "") + if display_name: + # OpenAlex даёт "Иван Иванов" → разбиваем + parts = display_name.strip().split() + if len(parts) >= 2: + authors_raw.append({ + "last_name": parts[-1], + "first_name": " ".join(parts[:-1]), + }) + elif parts: + authors_raw.append({"last_name": parts[0], "first_name": ""}) + + authors = self.normalize_authors(authors_raw) + + # Журнал + primary_location = raw.get("primary_location") or {} + source = primary_location.get("source") or {} + journal = source.get("display_name") + + # URL на полный текст + oa = raw.get("open_access") or {} + url = oa.get("oa_url") or primary_location.get("landing_page_url") + + # Аннотация (восстановить из инвертированного индекса) + abstract = None + inv_index = raw.get("abstract_inverted_index") + if inv_index: + abstract = _reconstruct_abstract(inv_index) + + # Бибинформация + biblio = raw.get("biblio") or {} + + # DOI + doi = raw.get("doi", "") + if doi and doi.startswith("https://doi.org/"): + doi = doi.replace("https://doi.org/", "") + + # Язык + lang = raw.get("language") + + return { + "source": self.source_name, + "ext_id": ext_id, + "doi": doi or None, + "title": (raw.get("title") or "").strip() or None, + "authors": authors, + "year": raw.get("publication_year"), + "lang": lang, + "journal": journal, + "volume": biblio.get("volume"), + "issue": biblio.get("issue"), + "pages": f"{biblio.get('first_page', '')}-{biblio.get('last_page', '')}".strip("-") or None, + "abstract": abstract, + "url": url, + "full_text": None, # Полный текст скачивается отдельно + } + + +def _reconstruct_abstract(inverted_index: dict) -> str: + """ + Восстановить аннотацию из инвертированного индекса OpenAlex. + + Инвертированный индекс: {слово: [позиция1, позиция2, ...], ...} + """ + try: + positions: dict[int, str] = {} + for word, pos_list in inverted_index.items(): + for pos in pos_list: + positions[pos] = word + return " ".join(positions[k] for k in sorted(positions.keys())) + except Exception: + return "" diff --git a/scripts/run_parser.py b/scripts/run_parser.py new file mode 100644 index 0000000..032627a --- /dev/null +++ b/scripts/run_parser.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""CLI для запуска парсеров источников. + +Использование: + python run_parser.py openalex --limit 10000 --query "машинное обучение" + python run_parser.py cyberleninka --limit 1000 --query "нейронные сети" + python run_parser.py arxiv --limit 5000 --categories cs.AI cs.LG + python run_parser.py all --limit 1000 +""" + +import argparse +import logging +import os +import sys +from pathlib import Path + +# Добавить директорию парсеров в путь +sys.path.insert(0, str(Path(__file__).parent / "parsers")) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + +logger = logging.getLogger(__name__) + + +def run_openalex(args: argparse.Namespace, output_dir: Path) -> None: + from openalex import OpenAlexParser + + parser = OpenAlexParser() + parser.run( + output_dir=output_dir, + query=args.query, + limit=args.limit, + lang=args.lang, + year_from=args.year_from, + year_to=args.year_to, + ) + + +def run_cyberleninka(args: argparse.Namespace, output_dir: Path) -> None: + from cyberleninka import CyberLeninkaParser + + parser = CyberLeninkaParser() + parser.run( + output_dir=output_dir, + query=args.query, + limit=args.limit, + ) + + +def run_arxiv(args: argparse.Namespace, output_dir: Path) -> None: + from arxiv import ArxivParser + + parser = ArxivParser() + parser.run( + output_dir=output_dir, + query=args.query, + limit=args.limit, + categories=args.categories, + year_from=args.year_from, + ) + + +def run_all(args: argparse.Namespace, output_dir: Path) -> None: + """Запустить все парсеры последовательно.""" + logger.info("Запуск всех парсеров...") + run_openalex(args, output_dir) + run_cyberleninka(args, output_dir) + run_arxiv(args, output_dir) + + +PARSERS = { + "openalex": run_openalex, + "cyberleninka": run_cyberleninka, + "arxiv": run_arxiv, + "all": run_all, +} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Запуск парсеров академических источников", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument( + "source", + choices=list(PARSERS.keys()), + help="Источник для парсинга", + ) + parser.add_argument( + "--query", "-q", + default="", + help="Поисковый запрос", + ) + parser.add_argument( + "--limit", "-n", + type=int, + default=1000, + help="Максимальное количество документов (default: 1000)", + ) + parser.add_argument( + "--output", "-o", + default="/data/processed", + help="Директория для сохранения JSONL (default: /data/processed)", + ) + parser.add_argument( + "--lang", + choices=["ru", "en"], + default=None, + help="Язык документов", + ) + parser.add_argument( + "--year-from", + type=int, + default=None, + dest="year_from", + help="Год публикации от", + ) + parser.add_argument( + "--year-to", + type=int, + default=None, + dest="year_to", + help="Год публикации до", + ) + parser.add_argument( + "--categories", + nargs="*", + default=None, + help="arXiv категории (например: cs.AI cs.LG math.ST)", + ) + + args = parser.parse_args() + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"Парсер: {args.source}, лимит: {args.limit}, запрос: {args.query!r}") + logger.info(f"Выходная директория: {output_dir}") + + run_fn = PARSERS[args.source] + run_fn(args, output_dir) + + logger.info("Парсинг завершён.") + + +if __name__ == "__main__": + main() diff --git a/services/api/Dockerfile b/services/api/Dockerfile new file mode 100644 index 0000000..063ecad --- /dev/null +++ b/services/api/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Создать директорию для логов +RUN mkdir -p /app/logs + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/api/alembic.ini b/services/api/alembic.ini new file mode 100644 index 0000000..de6165d --- /dev/null +++ b/services/api/alembic.ini @@ -0,0 +1,46 @@ +# Alembic Configuration File + +[alembic] +# Путь к директории с миграциями +script_location = alembic + +# Формат имени файла миграции +file_template = %%(rev)s_%%(slug)s + +# Часовой пояс для временных меток +timezone = Europe/Moscow + +# Логирование +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/services/api/alembic/__init__.py b/services/api/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api/alembic/env.py b/services/api/alembic/env.py new file mode 100644 index 0000000..8357e5a --- /dev/null +++ b/services/api/alembic/env.py @@ -0,0 +1,90 @@ +"""Alembic env.py — настройка среды для миграций (async режим).""" + +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +# Импорт всех моделей для автоопределения изменений +from app.database import Base +import app.models # noqa: F401 — регистрирует все модели + +# Alembic Config +config = context.config + +# Настройка логирования +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Целевые метаданные для автогенерации +target_metadata = Base.metadata + +# Читаем DATABASE URL из переменной окружения (синхронный psycopg2 для Alembic) +def get_url() -> str: + host = os.getenv("POSTGRES_HOST", "postgres") + port = os.getenv("POSTGRES_PORT", "5432") + db = os.getenv("POSTGRES_DB", "antiplagiator") + user = os.getenv("POSTGRES_USER", "antiplagiator") + password = os.getenv("POSTGRES_PASSWORD", "changeme") + return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{db}" + + +def run_migrations_offline() -> None: + """Запустить миграции в 'offline' режиме (без реального соединения с БД).""" + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Выполнить миграции с реальным соединением.""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Запустить миграции в async режиме.""" + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = get_url() + + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Запустить миграции в 'online' режиме.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/services/api/alembic/script.py.mako b/services/api/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/services/api/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/services/api/alembic/versions/001_initial_schema.py b/services/api/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..e624e8a --- /dev/null +++ b/services/api/alembic/versions/001_initial_schema.py @@ -0,0 +1,113 @@ +"""Начальная схема базы данных. + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00:00:00.000000 + +Создаёт таблицы: users, tasks, documents, fingerprints, usage_logs +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Создать все таблицы.""" + + # ── users ────────────────────────────────────────────────────────────────── + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("email", sa.String(255), nullable=False), + sa.Column("hashed_password", sa.String(255), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("plan", sa.String(20), nullable=False, server_default="free"), + sa.Column("verification_token", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + # ── tasks ────────────────────────────────────────────────────────────────── + op.create_table( + "tasks", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE")), + sa.Column("type", sa.String(20), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="queued"), + sa.Column("celery_task_id", sa.String(255), nullable=True), + sa.Column("input_data", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("result", sa.JSON(), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("queue_position", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_tasks_user_id", "tasks", ["user_id"]) + op.create_index("ix_tasks_status", "tasks", ["status"]) + + # ── documents ────────────────────────────────────────────────────────────── + op.create_table( + "documents", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("source", sa.String(50), nullable=False), + sa.Column("ext_id", sa.String(255), nullable=False), + sa.Column("doi", sa.String(255), nullable=True), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("authors", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("lang", sa.String(10), nullable=True), + sa.Column("journal", sa.Text(), nullable=True), + sa.Column("volume", sa.String(50), nullable=True), + sa.Column("issue", sa.String(50), nullable=True), + sa.Column("pages", sa.String(50), nullable=True), + sa.Column("abstract", sa.Text(), nullable=True), + sa.Column("url", sa.Text(), nullable=True), + sa.Column("minio_key", sa.Text(), nullable=True), + sa.Column("faiss_id", sa.Integer(), nullable=True), + sa.Column("indexed_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_documents_ext_id", "documents", ["ext_id"], unique=True) + op.create_index("ix_documents_doi", "documents", ["doi"]) + op.create_index("ix_documents_source", "documents", ["source"]) + op.create_index("ix_documents_year", "documents", ["year"]) + op.create_index("ix_documents_lang", "documents", ["lang"]) + op.create_index("ix_documents_faiss_id", "documents", ["faiss_id"]) + + # ── fingerprints ─────────────────────────────────────────────────────────── + op.create_table( + "fingerprints", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("doc_id", sa.Integer(), sa.ForeignKey("documents.id", ondelete="CASCADE")), + sa.Column("hash_value", sa.BigInteger(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + ) + op.create_index("ix_fingerprints_doc_id", "fingerprints", ["doc_id"]) + op.create_index("ix_fingerprints_hash_value", "fingerprints", ["hash_value"]) + + # ── usage_logs ───────────────────────────────────────────────────────────── + op.create_table( + "usage_logs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE")), + sa.Column("action", sa.String(50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_usage_logs_user_id", "usage_logs", ["user_id"]) + op.create_index("ix_usage_logs_created_at", "usage_logs", ["created_at"]) + + +def downgrade() -> None: + """Удалить все таблицы.""" + op.drop_table("usage_logs") + op.drop_table("fingerprints") + op.drop_table("documents") + op.drop_table("tasks") + op.drop_table("users") diff --git a/services/api/alembic/versions/__init__.py b/services/api/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api/app/__init__.py b/services/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api/app/api/__init__.py b/services/api/app/api/__init__.py new file mode 100644 index 0000000..4f0ef3e --- /dev/null +++ b/services/api/app/api/__init__.py @@ -0,0 +1 @@ +# API роутеры diff --git a/services/api/app/api/auth.py b/services/api/app/api/auth.py new file mode 100644 index 0000000..099e353 --- /dev/null +++ b/services/api/app/api/auth.py @@ -0,0 +1,119 @@ +"""Роутер аутентификации: регистрация, вход, верификация email.""" + +import secrets +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.celery_app import celery_app +from app.core.security import ( + create_access_token, + get_current_user, + hash_password, + verify_password, +) +from app.database import get_db +from app.models.user import User +from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserInToken + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse: + """ + Регистрация нового пользователя. + + Создаёт аккаунт и отправляет письмо с подтверждением email. + """ + # Проверить уникальность email + existing = await db.execute(select(User).where(User.email == data.email.lower())) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Пользователь с таким email уже существует", + ) + + # Создать токен верификации + verification_token = secrets.token_urlsafe(32) + + user = User( + email=data.email.lower(), + hashed_password=hash_password(data.password), + name=data.name, + verification_token=verification_token, + is_verified=False, + plan="free", + ) + db.add(user) + await db.flush() # Получить ID без коммита + await db.commit() + await db.refresh(user) + + # Диспатч email верификации через воркер + try: + celery_app.send_task( + "notify.send_verification", + args=[user.email, user.name, verification_token], + queue="queue.notify", + ) + except Exception as e: + logger.warning(f"Не удалось поставить задачу верификации email: {e}") + + access_token = create_access_token({"sub": str(user.id)}) + return TokenResponse( + access_token=access_token, + token_type="bearer", + user=UserInToken.model_validate(user), + ) + + +@router.post("/login", response_model=TokenResponse) +async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse: + """Вход в систему. Возвращает JWT токен.""" + result = await db.execute(select(User).where(User.email == data.email.lower())) + user = result.scalar_one_or_none() + + if user is None or not verify_password(data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверный email или пароль", + ) + + access_token = create_access_token({"sub": str(user.id)}) + return TokenResponse( + access_token=access_token, + token_type="bearer", + user=UserInToken.model_validate(user), + ) + + +@router.get("/me", response_model=UserInToken) +async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken: + """Получить информацию о текущем пользователе.""" + return UserInToken.model_validate(current_user) + + +@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK) +async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict: + """Подтвердить email по токену из письма.""" + result = await db.execute( + select(User).where(User.verification_token == token) + ) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Неверный или просроченный токен верификации", + ) + + user.is_verified = True + user.verification_token = None + await db.commit() + + return {"message": "Email успешно подтверждён"} diff --git a/services/api/app/api/documents.py b/services/api/app/api/documents.py new file mode 100644 index 0000000..00344d1 --- /dev/null +++ b/services/api/app/api/documents.py @@ -0,0 +1,155 @@ +"""Роутер загрузки документов на проверку плагиата.""" + +import logging +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from minio import Minio +from minio.error import S3Error +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.core.celery_app import celery_app +from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit +from app.core.security import get_current_user +from app.database import get_db +from app.models.task import Task +from app.models.user import User +from app.schemas.tasks import TaskResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/documents", tags=["documents"]) + +# Допустимые форматы +ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"} +MAX_FILE_SIZE_MB = 100 +MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 + + +def get_minio_client() -> Minio: + """Создать MinIO клиент.""" + return Minio( + settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=False, + ) + + +@router.post("/check", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED) +async def upload_for_plagiarism_check( + file: UploadFile = File(..., description="PDF, DOCX или TXT файл до 100 МБ"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> TaskResponse: + """ + Загрузить документ для проверки на плагиат. + + Файл сохраняется в MinIO, затем диспатчится задача index.extract_and_check. + Результат доступен через GET /tasks/{task_id}. + """ + # Проверить расширение файла + if not file.filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Имя файла не указано", + ) + + ext = Path(file.filename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=f"Неподдерживаемый формат файла. Допустимые форматы: {', '.join(ALLOWED_EXTENSIONS)}", + ) + + # Проверить лимит по тарифу + limit_check = check_and_increment_limit(current_user.id, "plagiarism", current_user.plan) + if not limit_check["allowed"]: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=( + f"Превышен месячный лимит проверок плагиата для тарифа '{current_user.plan}'. " + f"Использовано {limit_check['current']} из {limit_check['limit']}." + ), + ) + + # Проверить лимит одновременных задач + if not check_concurrent_limit(current_user.id, current_user.plan): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Превышен лимит одновременных задач.", + ) + + # Прочитать файл + file_data = await file.read() + + if len(file_data) > MAX_FILE_SIZE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Файл слишком большой. Максимальный размер: {MAX_FILE_SIZE_MB} МБ", + ) + + # Загрузить в MinIO + minio_key = f"uploads/{current_user.id}/{uuid.uuid4()}{ext}" + minio_client = get_minio_client() + + try: + # Создать бакет если не существует + if not minio_client.bucket_exists(settings.MINIO_BUCKET_DOCS): + minio_client.make_bucket(settings.MINIO_BUCKET_DOCS) + + import io + minio_client.put_object( + bucket_name=settings.MINIO_BUCKET_DOCS, + object_name=minio_key, + data=io.BytesIO(file_data), + length=len(file_data), + content_type=file.content_type or "application/octet-stream", + ) + logger.info(f"Файл загружен в MinIO: {minio_key}") + + except S3Error as e: + logger.error(f"Ошибка загрузки в MinIO: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Ошибка сохранения файла. Попробуйте позже.", + ) + + # Создать задачу в БД + task = Task( + user_id=current_user.id, + type="plagiarism", + status="queued", + input_data={ + "filename": file.filename, + "minio_key": minio_key, + "file_size_bytes": len(file_data), + "content_type": file.content_type, + }, + ) + db.add(task) + await db.flush() + task_id = task.id + + # Диспатч в индексер воркер + celery_result = celery_app.send_task( + "index.extract_and_check", + args=[task_id, minio_key, file.filename], + queue="queue.index", + ) + + task.celery_task_id = celery_result.id + task.queue_position = 1 + task.eta_seconds = 120 # ~2 минуты с учётом 4 уровней проверки + + await db.commit() + await db.refresh(task) + + logger.info( + f"Задача проверки плагиата {task_id!r} создана для пользователя {current_user.id}, " + f"файл: {file.filename!r}" + ) + + return TaskResponse.model_validate(task) diff --git a/services/api/app/api/reports.py b/services/api/app/api/reports.py new file mode 100644 index 0000000..752722b --- /dev/null +++ b/services/api/app/api/reports.py @@ -0,0 +1,77 @@ +"""Роутер для получения отчётов о выполненных задачах.""" + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import get_current_user +from app.database import get_db +from app.models.task import Task +from app.models.user import User + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/reports", tags=["reports"]) + + +@router.get("/{task_id}") +async def get_report( + task_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """ + Получить готовый отчёт по задаче. + + Возвращает task.result в зависимости от типа задачи: + - search: список источников с ГОСТ-цитатами + - plagiarism: детальный отчёт с совпадениями + - gost: отформатированная библиография + - summarize: краткое изложение + """ + result = await db.execute( + select(Task).where(Task.id == task_id, Task.user_id == current_user.id) + ) + task = result.scalar_one_or_none() + + if task is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задача не найдена", + ) + + if task.status == "queued": + raise HTTPException( + status_code=status.HTTP_202_ACCEPTED, + detail="Задача ещё в очереди", + ) + + if task.status == "processing": + raise HTTPException( + status_code=status.HTTP_202_ACCEPTED, + detail="Задача ещё выполняется", + ) + + if task.status == "failed": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Задача завершилась с ошибкой: {task.error}", + ) + + if task.result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Результат недоступен", + ) + + return { + "task_id": task.id, + "type": task.type, + "status": task.status, + "created_at": task.created_at.isoformat() if task.created_at else None, + "updated_at": task.updated_at.isoformat() if task.updated_at else None, + "result": task.result, + } diff --git a/services/api/app/api/search.py b/services/api/app/api/search.py new file mode 100644 index 0000000..5fe4ab7 --- /dev/null +++ b/services/api/app/api/search.py @@ -0,0 +1,95 @@ +"""Роутер семантического поиска источников.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.celery_app import celery_app +from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit +from app.core.security import get_current_user +from app.database import get_db +from app.models.task import Task +from app.models.user import User +from app.schemas.tasks import SearchRequest, TaskResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/search", tags=["search"]) + +# Примерное время ожидания в секундах для каждой позиции в очереди +ETA_PER_POSITION_SECONDS = 15 + + +@router.post("/", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED) +async def create_search_task( + data: SearchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> TaskResponse: + """ + Создать задачу семантического поиска источников. + + Возвращает TaskResponse с queue_position и eta_seconds сразу. + Результат доступен через GET /tasks/{task_id} или WebSocket /ws/tasks/{task_id}. + """ + # Проверить лимит по тарифу + limit_check = check_and_increment_limit(current_user.id, "search", current_user.plan) + if not limit_check["allowed"]: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=( + f"Превышен дневной лимит поиска для тарифа '{current_user.plan}'. " + f"Использовано {limit_check['current']} из {limit_check['limit']}." + ), + ) + + # Проверить лимит одновременных задач + if not check_concurrent_limit(current_user.id, current_user.plan): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Превышен лимит одновременных задач. Дождитесь завершения текущих.", + ) + + # Создать задачу в БД + task = Task( + user_id=current_user.id, + type="search", + status="queued", + input_data={ + "query": data.query, + "lang": data.lang, + "year_from": data.year_from, + "year_to": data.year_to, + "category": data.category, + }, + ) + db.add(task) + await db.flush() + task_id = task.id + + # Диспатч в GPU воркер + celery_result = celery_app.send_task( + "gpu.search_semantic", + args=[task_id, data.query], + kwargs={ + "lang": data.lang, + "year_from": data.year_from, + "year_to": data.year_to, + }, + queue="queue.gpu", + ) + + task.celery_task_id = celery_result.id + task.queue_position = 1 # TODO: реальный подсчёт через Redis + task.eta_seconds = ETA_PER_POSITION_SECONDS + + await db.commit() + await db.refresh(task) + + logger.info( + f"Задача поиска {task_id!r} создана для пользователя {current_user.id}, " + f"запрос: {data.query[:50]!r}" + ) + + return TaskResponse.model_validate(task) diff --git a/services/api/app/api/tasks.py b/services/api/app/api/tasks.py new file mode 100644 index 0000000..11a5ba4 --- /dev/null +++ b/services/api/app/api/tasks.py @@ -0,0 +1,89 @@ +"""Роутер для управления задачами пользователя.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import get_current_user +from app.database import get_db +from app.models.task import Task +from app.models.user import User +from app.schemas.tasks import TaskResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.get("/", response_model=list[TaskResponse]) +async def list_tasks( + limit: int = 20, + offset: int = 0, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[TaskResponse]: + """Получить список задач текущего пользователя (от новых к старым).""" + result = await db.execute( + select(Task) + .where(Task.user_id == current_user.id) + .order_by(Task.created_at.desc()) + .limit(limit) + .offset(offset) + ) + tasks = result.scalars().all() + return [TaskResponse.model_validate(t) for t in tasks] + + +@router.get("/{task_id}", response_model=TaskResponse) +async def get_task( + task_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> TaskResponse: + """Получить детали конкретной задачи.""" + result = await db.execute( + select(Task).where(Task.id == task_id, Task.user_id == current_user.id) + ) + task = result.scalar_one_or_none() + + if task is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задача не найдена", + ) + + return TaskResponse.model_validate(task) + + +@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task( + task_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """ + Удалить задачу. + + Нельзя удалить задачу в статусе 'processing'. + """ + result = await db.execute( + select(Task).where(Task.id == task_id, Task.user_id == current_user.id) + ) + task = result.scalar_one_or_none() + + if task is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задача не найдена", + ) + + if task.status == "processing": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Нельзя удалить задачу в процессе выполнения", + ) + + await db.delete(task) + await db.commit() diff --git a/services/api/app/config.py b/services/api/app/config.py new file mode 100644 index 0000000..ac60be1 --- /dev/null +++ b/services/api/app/config.py @@ -0,0 +1,82 @@ +"""Конфигурация приложения через Pydantic Settings.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Настройки приложения, читаемые из переменных окружения.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # PostgreSQL + POSTGRES_HOST: str = "postgres" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "antiplagiator" + POSTGRES_USER: str = "antiplagiator" + POSTGRES_PASSWORD: str = "changeme" + + # Redis + REDIS_URL: str = "redis://redis:6379/0" + + # RabbitMQ + RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/" + + # MinIO + MINIO_ENDPOINT: str = "minio:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "changeme" + MINIO_BUCKET_DOCS: str = "documents" + MINIO_BUCKET_BACKUPS: str = "backups" + + # Elasticsearch + ELASTICSEARCH_URL: str = "http://elasticsearch:9200" + + # Ollama + OLLAMA_URL: str = "http://ollama:11434" + + # JWT + SECRET_KEY: str = "change-me-in-production-use-openssl-rand-hex-32" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней + + # SMTP + SMTP_HOST: str = "smtp.yandex.ru" + SMTP_PORT: int = 465 + SMTP_USER: str = "noreply@jze9.ru" + SMTP_PASSWORD: str = "changeme" + SMTP_FROM: str = "noreply@jze9.ru" + + # App + APP_URL: str = "https://academic.jze9.ru" + ENVIRONMENT: str = "development" + DEBUG: bool = False + + # CORS + CORS_ORIGINS: list[str] = [ + "http://localhost:5173", + "http://localhost:3000", + "https://academic.jze9.ru", + ] + + @property + def database_url(self) -> str: + """URL для asyncpg (асинхронные запросы).""" + return ( + f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + @property + def database_url_sync(self) -> str: + """URL для psycopg2 (Alembic и синхронные операции).""" + return ( + f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + +settings = Settings() diff --git a/services/api/app/core/__init__.py b/services/api/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api/app/core/celery_app.py b/services/api/app/core/celery_app.py new file mode 100644 index 0000000..defcaef --- /dev/null +++ b/services/api/app/core/celery_app.py @@ -0,0 +1,39 @@ +"""Celery приложение для API-диспатчера. Только определение — задачи находятся в воркерах.""" + +from celery import Celery + +from app.config import settings + +celery_app = Celery( + "api_dispatcher", + broker=settings.RABBITMQ_URL, + backend=settings.REDIS_URL, +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Europe/Moscow", + enable_utc=True, + task_track_started=True, + # Маршрутизация задач по очередям воркеров + task_routes={ + "gpu.*": {"queue": "queue.gpu"}, + "index.*": {"queue": "queue.index"}, + "notify.*": {"queue": "queue.notify"}, + "gost.*": {"queue": "queue.gost"}, + }, + # Настройки очередей + task_queues={ + "queue.gpu": {"exchange": "queue.gpu", "routing_key": "queue.gpu"}, + "queue.index": {"exchange": "queue.index", "routing_key": "queue.index"}, + "queue.notify": {"exchange": "queue.notify", "routing_key": "queue.notify"}, + "queue.gost": {"exchange": "queue.gost", "routing_key": "queue.gost"}, + }, + # Результаты хранить 24 часа + result_expires=86400, + # Повторные попытки + task_acks_late=True, + task_reject_on_worker_lost=True, +) diff --git a/services/api/app/core/rate_limiter.py b/services/api/app/core/rate_limiter.py new file mode 100644 index 0000000..973acfa --- /dev/null +++ b/services/api/app/core/rate_limiter.py @@ -0,0 +1,167 @@ +"""Redis-based rate limiter для проверки лимитов по тарифному плану.""" + +import json +from datetime import datetime, timezone + +import redis + +from app.config import settings + +# Лимиты по тарифным планам +PLAN_LIMITS: dict[str, dict[str, int | None]] = { + "free": { + "search_per_day": 10, + "summarize_per_month": 3, + "plagiarism_per_month": 1, + "concurrent": 1, + }, + "student": { + "search_per_day": None, # None = безлимит + "summarize_per_month": 30, + "plagiarism_per_month": 10, + "concurrent": 2, + }, + "premium": { + "search_per_day": None, + "summarize_per_month": None, + "plagiarism_per_month": 50, + "concurrent": 5, + }, + "science": { + "search_per_day": None, + "summarize_per_month": None, + "plagiarism_per_month": None, + "concurrent": 10, + }, +} + +# Маппинг действий на ключи лимитов +ACTION_TO_LIMIT: dict[str, tuple[str, str]] = { + "search": ("search_per_day", "day"), + "summarize": ("summarize_per_month", "month"), + "plagiarism": ("plagiarism_per_month", "month"), + "gost": ("gost_per_month", "month"), # ГОСТ всегда разрешён +} + + +def get_redis_client() -> redis.Redis: + """Создать синхронный Redis клиент.""" + return redis.from_url(settings.REDIS_URL, decode_responses=True) + + +def _get_period_key(period: str) -> str: + """Получить строку периода для Redis ключа.""" + now = datetime.now(timezone.utc) + if period == "day": + return now.strftime("%Y-%m-%d") + elif period == "month": + return now.strftime("%Y-%m") + return now.strftime("%Y-%m-%d") + + +def check_and_increment_limit(user_id: int, action: str, plan: str) -> dict: + """ + Проверить лимит и инкрементировать счётчик. + + Args: + user_id: ID пользователя + action: Действие (search, plagiarism, summarize, gost) + plan: Тарифный план пользователя + + Returns: + dict с полями: + - allowed: bool — разрешено ли действие + - current: int — текущее количество использований + - limit: int | None — лимит (None = безлимит) + - remaining: int | None — осталось использований + - reset_at: str — когда сбрасывается счётчик + """ + limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]) + + if action not in ACTION_TO_LIMIT: + # Неизвестное действие — разрешаем + return {"allowed": True, "current": 0, "limit": None, "remaining": None} + + limit_key, period = ACTION_TO_LIMIT[action] + limit_value = limits.get(limit_key) + + # Безлимитный план + if limit_value is None: + return { + "allowed": True, + "current": 0, + "limit": None, + "remaining": None, + "reset_at": None, + } + + period_str = _get_period_key(period) + redis_key = f"rate:{user_id}:{action}:{period_str}" + + r = get_redis_client() + + # Атомарно инкрементировать + pipe = r.pipeline() + pipe.incr(redis_key) + # Устанавливаем TTL: для дня — 86400 сек, для месяца — 32 дня + ttl = 86400 if period == "day" else 86400 * 32 + pipe.expire(redis_key, ttl) + results = pipe.execute() + + current = results[0] + + if current > limit_value: + # Декрементировать обратно (не считать запрещённые) + r.decr(redis_key) + current -= 1 + return { + "allowed": False, + "current": current, + "limit": limit_value, + "remaining": 0, + "reset_at": period_str, + } + + return { + "allowed": True, + "current": current, + "limit": limit_value, + "remaining": limit_value - current, + "reset_at": period_str, + } + + +def check_concurrent_limit(user_id: int, plan: str) -> bool: + """ + Проверить лимит одновременных задач. + + Returns: + True если можно создать новую задачу, False если превышен лимит. + """ + limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]) + max_concurrent = limits.get("concurrent", 1) + + r = get_redis_client() + key = f"concurrent:{user_id}" + current = r.get(key) + + return (current is None) or (int(current) < max_concurrent) + + +def increment_concurrent(user_id: int) -> None: + """Увеличить счётчик одновременных задач (при создании задачи).""" + r = get_redis_client() + key = f"concurrent:{user_id}" + pipe = r.pipeline() + pipe.incr(key) + pipe.expire(key, 3600) # Автосброс через 1 час + pipe.execute() + + +def decrement_concurrent(user_id: int) -> None: + """Уменьшить счётчик одновременных задач (при завершении задачи).""" + r = get_redis_client() + key = f"concurrent:{user_id}" + current = r.get(key) + if current and int(current) > 0: + r.decr(key) diff --git a/services/api/app/core/security.py b/services/api/app/core/security.py new file mode 100644 index 0000000..f37dd2d --- /dev/null +++ b/services/api/app/core/security.py @@ -0,0 +1,110 @@ +"""Утилиты безопасности: JWT, хэширование паролей, dependency для получения текущего пользователя.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db + +# Контекст хэширования паролей (bcrypt) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# OAuth2 схема +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def hash_password(password: str) -> str: + """Хэшировать пароль через bcrypt.""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Проверить соответствие пароля его хэшу.""" + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + """Создать JWT токен доступа.""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def verify_token(token: str) -> dict[str, Any]: + """ + Декодировать и верифицировать JWT токен. + + Raises: + HTTPException: если токен невалиден или просрочен. + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id: int | None = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверный токен аутентификации", + headers={"WWW-Authenticate": "Bearer"}, + ) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Невалидный или просроченный токен", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +): + """ + FastAPI dependency: получить текущего авторизованного пользователя из JWT токена. + + Raises: + HTTPException 401: если токен невалиден. + HTTPException 404: если пользователь не найден. + """ + from app.models.user import User + + payload = verify_token(token) + user_id: int = int(payload.get("sub")) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден", + ) + + return user + + +async def get_current_verified_user( + current_user=Depends(get_current_user), +): + """ + FastAPI dependency: только верифицированные пользователи. + + Raises: + HTTPException 403: если email не подтверждён. + """ + if not current_user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Необходимо подтвердить email адрес", + ) + return current_user diff --git a/services/api/app/core/websocket_manager.py b/services/api/app/core/websocket_manager.py new file mode 100644 index 0000000..5767a4c --- /dev/null +++ b/services/api/app/core/websocket_manager.py @@ -0,0 +1,86 @@ +"""WebSocket менеджер для real-time обновлений статуса задач.""" + +import json +import logging +from typing import Any + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + + +class ConnectionManager: + """Менеджер WebSocket соединений с поддержкой подписки на задачи.""" + + def __init__(self) -> None: + # task_id -> список активных соединений + self._connections: dict[str, list[WebSocket]] = {} + + async def connect(self, task_id: str, websocket: WebSocket) -> None: + """Принять WebSocket соединение и зарегистрировать его для задачи.""" + await websocket.accept() + if task_id not in self._connections: + self._connections[task_id] = [] + self._connections[task_id].append(websocket) + logger.info(f"WebSocket подключён к задаче {task_id!r}") + + def disconnect(self, task_id: str, websocket: WebSocket) -> None: + """Удалить соединение из реестра.""" + if task_id in self._connections: + try: + self._connections[task_id].remove(websocket) + except ValueError: + pass + if not self._connections[task_id]: + del self._connections[task_id] + logger.info(f"WebSocket отключён от задачи {task_id!r}") + + async def send_task_update(self, task_id: str, data: dict[str, Any]) -> None: + """ + Отправить обновление статуса задачи всем подключённым клиентам. + + Args: + task_id: ID задачи + data: Словарь с обновлением (status, queue_position, result и т.д.) + """ + connections = self._connections.get(task_id, []) + if not connections: + return + + message = json.dumps(data, ensure_ascii=False, default=str) + dead_connections = [] + + for websocket in connections: + try: + await websocket.send_text(message) + except Exception as e: + logger.warning(f"Ошибка отправки WebSocket сообщения: {e}") + dead_connections.append(websocket) + + # Очистить мёртвые соединения + for ws in dead_connections: + self.disconnect(task_id, ws) + + async def broadcast(self, data: dict[str, Any]) -> None: + """Отправить сообщение всем подключённым клиентам.""" + message = json.dumps(data, ensure_ascii=False, default=str) + all_dead = [] + + for task_id, connections in self._connections.items(): + for websocket in connections: + try: + await websocket.send_text(message) + except Exception: + all_dead.append((task_id, websocket)) + + for task_id, ws in all_dead: + self.disconnect(task_id, ws) + + @property + def active_connections_count(self) -> int: + """Количество активных WebSocket соединений.""" + return sum(len(conns) for conns in self._connections.values()) + + +# Глобальный экземпляр менеджера +ws_manager = ConnectionManager() diff --git a/services/api/app/database.py b/services/api/app/database.py new file mode 100644 index 0000000..7f305e8 --- /dev/null +++ b/services/api/app/database.py @@ -0,0 +1,53 @@ +"""Настройка базы данных: AsyncEngine, AsyncSession, dependency для FastAPI.""" + +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + + +class Base(DeclarativeBase): + """Базовый класс для всех SQLAlchemy моделей.""" + pass + + +# Асинхронный движок +engine = create_async_engine( + settings.database_url, + echo=settings.DEBUG, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, + pool_recycle=3600, +) + +# Фабрика сессий +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + FastAPI dependency для получения сессии БД. + Сессия автоматически закрывается после запроса. + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/services/api/app/main.py b/services/api/app/main.py new file mode 100644 index 0000000..2e6ea1c --- /dev/null +++ b/services/api/app/main.py @@ -0,0 +1,113 @@ +"""Точка входа FastAPI приложения — Академический помощник (anti-plagiarism).""" + +import logging +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.api import auth, documents, reports, search, tasks +from app.config import settings +from app.core.websocket_manager import ws_manager +from app.database import engine + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """ + Lifecycle менеджер приложения. + Выполняет инициализацию при старте и очистку при остановке. + """ + logger.info("Запуск Академического помощника...") + + # Проверка соединений при старте + try: + async with engine.connect() as conn: + await conn.execute(__import__("sqlalchemy").text("SELECT 1")) + logger.info("PostgreSQL: соединение установлено") + except Exception as e: + logger.error(f"PostgreSQL: ошибка соединения: {e}") + + yield + + # Очистка при остановке + logger.info("Остановка приложения...") + await engine.dispose() + + +app = FastAPI( + title="Академический помощник API", + description=( + "Сервис антиплагиата и поиска академических источников. " + "Семантический поиск через FAISS GPU + Elasticsearch, " + "проверка плагиата в 4 уровня, ГОСТ библиография." + ), + version="0.1.0", + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", +) + +# ─── CORS ───────────────────────────────────────────────────────────────────── +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ─── Роутеры ────────────────────────────────────────────────────────────────── +app.include_router(auth.router, prefix="/api") +app.include_router(tasks.router, prefix="/api") +app.include_router(search.router, prefix="/api") +app.include_router(documents.router, prefix="/api") +app.include_router(reports.router, prefix="/api") + + +# ─── WebSocket ──────────────────────────────────────────────────────────────── +@app.websocket("/ws/tasks/{task_id}") +async def websocket_task_updates(websocket: WebSocket, task_id: str) -> None: + """ + WebSocket endpoint для real-time обновлений статуса задачи. + + Клиент подключается и получает обновления при изменении статуса задачи. + Соединение закрывается при получении статуса done/failed. + """ + await ws_manager.connect(task_id, websocket) + try: + while True: + # Ждём входящих сообщений (ping/pong для поддержания соединения) + data = await websocket.receive_text() + if data == "ping": + await websocket.send_text("pong") + except WebSocketDisconnect: + ws_manager.disconnect(task_id, websocket) + logger.info(f"WebSocket клиент отключился от задачи {task_id!r}") + + +# ─── Health check ────────────────────────────────────────────────────────────── +@app.get("/health", tags=["monitoring"]) +async def health_check() -> JSONResponse: + """Проверка работоспособности сервиса.""" + return JSONResponse( + content={ + "status": "ok", + "service": "api", + "version": "0.1.0", + } + ) + + +@app.get("/", include_in_schema=False) +async def root() -> JSONResponse: + """Корневой endpoint — редирект к документации.""" + return JSONResponse( + content={"message": "Академический помощник API", "docs": "/api/docs"} + ) diff --git a/services/api/app/schemas/__init__.py b/services/api/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api/app/schemas/auth.py b/services/api/app/schemas/auth.py new file mode 100644 index 0000000..e738297 --- /dev/null +++ b/services/api/app/schemas/auth.py @@ -0,0 +1,38 @@ +"""Схемы для аутентификации и авторизации.""" + +from pydantic import BaseModel, EmailStr, Field + + +class RegisterRequest(BaseModel): + """Запрос на регистрацию нового пользователя.""" + + email: EmailStr + password: str = Field(min_length=8, max_length=128) + name: str = Field(min_length=1, max_length=255) + + +class LoginRequest(BaseModel): + """Запрос на вход в систему.""" + + email: EmailStr + password: str + + +class UserInToken(BaseModel): + """Минимальная информация о пользователе в JWT токене.""" + + id: int + email: str + name: str + plan: str + is_verified: bool + + model_config = {"from_attributes": True} + + +class TokenResponse(BaseModel): + """Ответ с JWT токеном и данными пользователя.""" + + access_token: str + token_type: str = "bearer" + user: UserInToken diff --git a/services/api/app/schemas/reports.py b/services/api/app/schemas/reports.py new file mode 100644 index 0000000..76282e2 --- /dev/null +++ b/services/api/app/schemas/reports.py @@ -0,0 +1,46 @@ +"""Схемы для отчётов о плагиате и результатов поиска.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class PlagiarismMatch(BaseModel): + """Совпадение фрагмента с источником.""" + + fragment: str + position_start: int + position_end: int + similarity: float = Field(ge=0.0, le=100.0, description="Схожесть в процентах") + method: str = Field(description="Метод обнаружения: exact, fuzzy, semantic+llm") + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + reason: str | None = None + source_title: str + source_url: str | None = None + source_db: str = Field(description="База данных: openalex, cyberleninka, arxiv, и т.д.") + + +class PlagiarismReport(BaseModel): + """Полный отчёт о плагиате.""" + + task_id: str + overall_similarity: float = Field(ge=0.0, le=100.0) + matches: list[PlagiarismMatch] = Field(default_factory=list) + total_fragments: int + flagged_fragments: int + checked_at: datetime + + +class SearchResult(BaseModel): + """Источник в результатах поиска.""" + + id: int + title: str + authors: list[dict] = Field(default_factory=list) + year: int | None = None + journal: str | None = None + abstract: str | None = None + url: str | None = None + relevance_score: float = Field(ge=0.0, le=1.0) + gost_citation: str = Field(description="Отформатированная ГОСТ-цитата") + source_db: str diff --git a/services/api/app/schemas/tasks.py b/services/api/app/schemas/tasks.py new file mode 100644 index 0000000..2c50df3 --- /dev/null +++ b/services/api/app/schemas/tasks.py @@ -0,0 +1,39 @@ +"""Схемы для задач и поиска.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class TaskCreate(BaseModel): + """Создание задачи.""" + + type: str + input_data: dict[str, Any] = Field(default_factory=dict) + + +class TaskResponse(BaseModel): + """Ответ с информацией о задаче.""" + + id: str + type: str + status: str + queue_position: int | None = None + eta_seconds: int | None = None + input_data: dict[str, Any] = Field(default_factory=dict) + result: dict[str, Any] | None = None + error: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class SearchRequest(BaseModel): + """Запрос на семантический поиск источников.""" + + query: str = Field(min_length=3, max_length=1000) + lang: str | None = Field(default=None, description="Язык: ru, en или None для всех") + year_from: int | None = Field(default=None, ge=1900, le=2100) + year_to: int | None = Field(default=None, ge=1900, le=2100) + category: str | None = Field(default=None, description="Тематическая категория") diff --git a/services/api/requirements.txt b/services/api/requirements.txt new file mode 100644 index 0000000..9b021bc --- /dev/null +++ b/services/api/requirements.txt @@ -0,0 +1,17 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.30 +alembic==1.13.1 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +redis==5.0.4 +celery==5.4.0 +pydantic==2.7.1 +pydantic-settings==2.2.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +minio==7.2.7 +httpx==0.27.0 +aiofiles==23.2.1 +websockets==12.0 diff --git a/services/frontend/index.html b/services/frontend/index.html new file mode 100644 index 0000000..cda060f --- /dev/null +++ b/services/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Академический помощник + + +
+ + + diff --git a/services/frontend/package.json b/services/frontend/package.json new file mode 100644 index 0000000..fb216ed --- /dev/null +++ b/services/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "academic-helper-frontend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.23.0", + "@tanstack/react-query": "^5.40.0", + "zustand": "^4.5.2", + "axios": "^1.7.2", + "react-dropzone": "^14.2.3", + "react-hot-toast": "^2.4.1", + "lucide-react": "^0.390.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.3.0", + "date-fns": "^3.6.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.4.5", + "vite": "^5.2.12", + "tailwindcss": "^3.4.4", + "postcss": "^8.4.38", + "autoprefixer": "^10.4.19", + "eslint": "^9.4.0" + } +} diff --git a/services/frontend/postcss.config.js b/services/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/services/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/services/frontend/src/api/client.ts b/services/frontend/src/api/client.ts new file mode 100644 index 0000000..595426c --- /dev/null +++ b/services/frontend/src/api/client.ts @@ -0,0 +1,80 @@ +import axios from 'axios'; +import { useAuthStore } from '../store/auth'; + +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || '/api', + headers: { 'Content-Type': 'application/json' }, + timeout: 30000, +}); + +// Добавить JWT токен к каждому запросу +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Обработка ответов: при 401 — разлогинить +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + useAuthStore.getState().logout(); + } + return Promise.reject(error); + } +); + +// ─── API методы ─────────────────────────────────────────────────────────────── + +export const authApi = { + register: (data: { email: string; password: string; name: string }) => + api.post('/auth/register', data), + + login: (data: { email: string; password: string }) => + api.post('/auth/login', data), + + me: () => api.get('/auth/me'), + + verifyEmail: (token: string) => + api.post(`/auth/verify-email/${token}`), +}; + +export const tasksApi = { + list: (limit = 20, offset = 0) => + api.get('/tasks/', { params: { limit, offset } }), + + get: (taskId: string) => + api.get(`/tasks/${taskId}`), + + delete: (taskId: string) => + api.delete(`/tasks/${taskId}`), +}; + +export const searchApi = { + create: (data: { + query: string; + lang?: string; + year_from?: number; + year_to?: number; + category?: string; + }) => api.post('/search/', data), +}; + +export const documentsApi = { + uploadForCheck: (file: File) => { + const formData = new FormData(); + formData.append('file', file); + return api.post('/documents/check', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000, // 2 минуты для загрузки + }); + }, +}; + +export const reportsApi = { + get: (taskId: string) => + api.get(`/reports/${taskId}`), +}; diff --git a/services/frontend/src/components/DropZone.tsx b/services/frontend/src/components/DropZone.tsx new file mode 100644 index 0000000..5ef36c3 --- /dev/null +++ b/services/frontend/src/components/DropZone.tsx @@ -0,0 +1,104 @@ +import React, { useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { Upload, FileText, X } from 'lucide-react'; +import { clsx } from 'clsx'; + +interface DropZoneProps { + onFile: (file: File) => void; + file?: File | null; + onClear?: () => void; +} + +const ACCEPTED_TYPES = { + 'application/pdf': ['.pdf'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'text/plain': ['.txt'], +}; + +const MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB +const MAX_SIZE_LABEL = '100 МБ'; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Б`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`; + return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`; +} + +export function DropZone({ onFile, file, onClear }: DropZoneProps) { + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + onFile(acceptedFiles[0]); + } + }, + [onFile] + ); + + const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ + onDrop, + accept: ACCEPTED_TYPES, + maxSize: MAX_SIZE_BYTES, + maxFiles: 1, + }); + + const rejectionError = fileRejections[0]?.errors[0]?.message; + + if (file) { + return ( +
+
+ +
+
+

{file.name}

+

{formatFileSize(file.size)}

+
+ {onClear && ( + + )} +
+ ); + } + + return ( +
+
+ + + {isDragActive ? ( +

Отпустите файл для загрузки

+ ) : ( + <> +

+ Перетащите файл или нажмите для выбора +

+

+ PDF, DOCX, TXT — до {MAX_SIZE_LABEL} +

+ + )} +
+ {rejectionError && ( +

{rejectionError}

+ )} +
+ ); +} diff --git a/services/frontend/src/components/GostCitation.tsx b/services/frontend/src/components/GostCitation.tsx new file mode 100644 index 0000000..2a87c94 --- /dev/null +++ b/services/frontend/src/components/GostCitation.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Copy, Check } from 'lucide-react'; +import { useState } from 'react'; + +interface GostCitationProps { + citation: string; + number?: number; +} + +export function GostCitation({ citation, number }: GostCitationProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(citation).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( +
+ {number !== undefined && ( + + {number}. + + )} +

{citation}

+ +
+ ); +} diff --git a/services/frontend/src/components/Layout.tsx b/services/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..abe849c --- /dev/null +++ b/services/frontend/src/components/Layout.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react'; +import { clsx } from 'clsx'; +import { useAuthStore } from '../store/auth'; + +interface LayoutProps { + children: React.ReactNode; +} + +export function Layout({ children }: LayoutProps) { + const { isAuthenticated, user, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/'); + }; + + return ( +
+ {/* Навигация */} + + + {/* Основной контент */} +
+ {children} +
+
+ ); +} diff --git a/services/frontend/src/components/PlagiarismReport.tsx b/services/frontend/src/components/PlagiarismReport.tsx new file mode 100644 index 0000000..1b86750 --- /dev/null +++ b/services/frontend/src/components/PlagiarismReport.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { AlertTriangle, CheckCircle, Info } from 'lucide-react'; +import { clsx } from 'clsx'; +import type { PlagiarismResultData } from '../types'; + +interface PlagiarismReportProps { + data: PlagiarismResultData; +} + +function getSimilarityLevel(pct: number): { + color: string; + bgColor: string; + borderColor: string; + icon: typeof CheckCircle; + label: string; +} { + if (pct > 30) { + return { + color: 'text-red-700', + bgColor: 'bg-red-50', + borderColor: 'border-red-200', + icon: AlertTriangle, + label: 'Высокий уровень схожести', + }; + } + if (pct > 10) { + return { + color: 'text-yellow-700', + bgColor: 'bg-yellow-50', + borderColor: 'border-yellow-200', + icon: Info, + label: 'Умеренный уровень схожести', + }; + } + return { + color: 'text-green-700', + bgColor: 'bg-green-50', + borderColor: 'border-green-200', + icon: CheckCircle, + label: 'Низкий уровень схожести', + }; +} + +const METHOD_LABELS: Record = { + exact: 'Точное совпадение', + fuzzy: 'Нечёткое совпадение', + 'semantic+llm': 'Семантика + LLM', +}; + +export function PlagiarismReport({ data }: PlagiarismReportProps) { + const level = getSimilarityLevel(data.overall_similarity); + const Icon = level.icon; + + return ( +
+ {/* Итоговый показатель */} +
+
+
+ {data.overall_similarity.toFixed(1)}% +
+
+
+ + {level.label} +
+

+ Проверено фрагментов: {data.total_fragments} · Выявлено совпадений: {data.flagged_fragments} +

+
+
+ + {/* Прогресс-бар */} +
+
30, + 'bg-yellow-400': data.overall_similarity > 10 && data.overall_similarity <= 30, + 'bg-green-500': data.overall_similarity <= 10, + })} + style={{ width: `${Math.min(data.overall_similarity, 100)}%` }} + /> +
+
+ + {/* Методы обнаружения */} + {data.by_method && ( +
+ {[ + { key: 'exact', label: 'Точные', count: data.by_method.exact }, + { key: 'fuzzy', label: 'Нечёткие', count: data.by_method.fuzzy }, + { key: 'semantic_llm', label: 'Семантика+LLM', count: data.by_method.semantic_llm }, + ].map(({ key, label, count }) => ( +
+
{count}
+
{label}
+
+ ))} +
+ )} + + {/* Список совпадений */} + {data.matches.length > 0 && ( +
+

+ Обнаруженные совпадения ({data.matches.length}) +

+
+ {data.matches.map((match, i) => ( +
+ {/* Шапка совпадения */} +
+ + {match.source_title} + + 70 + ? 'bg-red-100 text-red-700' + : match.similarity > 40 + ? 'bg-yellow-100 text-yellow-700' + : 'bg-gray-100 text-gray-600' + )}> + {match.similarity.toFixed(0)}% + + + {METHOD_LABELS[match.method] || match.method} + +
+ {/* Фрагмент */} +
+

+ «{match.fragment}» +

+ {match.reason && ( +

+ {match.reason} +

+ )} + {match.source_url && ( + + Открыть источник → + + )} +
+
+ ))} +
+
+ )} + + {data.matches.length === 0 && ( +
+ +

Совпадений не обнаружено

+
+ )} +
+ ); +} diff --git a/services/frontend/src/components/SearchBar.tsx b/services/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..e4bd732 --- /dev/null +++ b/services/frontend/src/components/SearchBar.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { Search } from 'lucide-react'; +import { clsx } from 'clsx'; + +interface SearchBarProps { + onSearch: (query: string) => void; + defaultValue?: string; + placeholder?: string; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; + className?: string; +} + +export function SearchBar({ + onSearch, + defaultValue = '', + placeholder = 'Введите тему или запрос для поиска источников...', + size = 'md', + isLoading = false, + className, +}: SearchBarProps) { + const [query, setQuery] = useState(defaultValue); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (trimmed.length < 3) return; + onSearch(trimmed); + }; + + return ( +
+
+ + setQuery(e.target.value)} + placeholder={placeholder} + className={clsx( + 'w-full bg-white border border-gray-200 rounded-xl shadow-sm', + 'placeholder:text-gray-400 text-gray-900', + 'focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent', + 'transition-all duration-200', + size === 'lg' && 'pl-14 pr-36 py-5 text-lg', + size === 'md' && 'pl-12 pr-28 py-3 text-base', + size === 'sm' && 'pl-10 pr-24 py-2 text-sm' + )} + minLength={3} + maxLength={1000} + required + /> + +
+
+ ); +} diff --git a/services/frontend/src/components/SourceCard.tsx b/services/frontend/src/components/SourceCard.tsx new file mode 100644 index 0000000..584da75 --- /dev/null +++ b/services/frontend/src/components/SourceCard.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react'; +import { clsx } from 'clsx'; +import toast from 'react-hot-toast'; +import { useBibliographyStore } from '../store/bibliography'; +import type { SearchSource } from '../types'; + +interface SourceCardProps { + source: SearchSource; +} + +const SOURCE_DB_LABELS: Record = { + openalex: 'OpenAlex', + cyberleninka: 'КиберЛенинка', + arxiv: 'arXiv', + wikipedia_ru: 'Wikipedia RU', + wikipedia_en: 'Wikipedia EN', +}; + +function getRelevanceColor(score: number): string { + if (score >= 0.7) return 'text-green-700 bg-green-50 border-green-200'; + if (score >= 0.4) return 'text-yellow-700 bg-yellow-50 border-yellow-200'; + return 'text-gray-600 bg-gray-50 border-gray-200'; +} + +export function SourceCard({ source }: SourceCardProps) { + const { addSource, removeSource, hasSource } = useBibliographyStore(); + const inBibliography = hasSource(source.id); + + const handleToggleBibliography = () => { + if (inBibliography) { + removeSource(source.id); + toast.success('Удалено из библиографии'); + } else { + addSource(source); + toast.success('Добавлено в библиографию'); + } + }; + + const handleCopyCitation = () => { + navigator.clipboard.writeText(source.gost_citation).then(() => { + toast.success('ГОСТ-цитата скопирована'); + }); + }; + + const authorsStr = source.authors + .slice(0, 3) + .map((a) => `${a.last_name} ${a.initials || ''}`.trim()) + .join(', '); + + return ( +
+ {/* Заголовок и значки */} +
+
+

+ {source.title} +

+
+ {authorsStr && ( + {authorsStr} + )} + {source.year && ( + {source.year} + )} + {source.journal && ( + {source.journal} + )} +
+
+ + {/* Бейдж релевантности */} +
+ {(source.relevance_score * 100).toFixed(0)}% +
+
+ + {/* Аннотация */} + {source.abstract && ( +

+ {source.abstract} +

+ )} + + {/* ГОСТ-цитата */} +
+

ГОСТ-цитата:

+

{source.gost_citation}

+
+ + {/* Действия */} +
+ {/* Источник */} + + {SOURCE_DB_LABELS[source.source_db] || source.source_db} + + +
+ {/* Открыть источник */} + {source.url && ( + + + Открыть + + )} + + {/* Копировать цитату */} + + + {/* В библиографию */} + +
+
+
+ ); +} diff --git a/services/frontend/src/components/StatusBadge.tsx b/services/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..8c83f2d --- /dev/null +++ b/services/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { clsx } from 'clsx'; +import type { TaskStatus } from '../types'; + +interface StatusBadgeProps { + status: TaskStatus; + className?: string; +} + +const STATUS_CONFIG: Record = { + queued: { + label: 'В очереди', + className: 'bg-gray-100 text-gray-600 border-gray-200', + }, + processing: { + label: 'Выполняется', + className: 'bg-blue-50 text-blue-700 border-blue-200 animate-pulse', + }, + done: { + label: 'Готово', + className: 'bg-green-50 text-green-700 border-green-200', + }, + failed: { + label: 'Ошибка', + className: 'bg-red-50 text-red-700 border-red-200', + }, +}; + +export function StatusBadge({ status, className }: StatusBadgeProps) { + const config = STATUS_CONFIG[status]; + + return ( + + {status === 'processing' && ( + + )} + {status === 'done' && ( + + )} + {status === 'failed' && ( + + )} + {status === 'queued' && ( + + )} + {config.label} + + ); +} diff --git a/services/frontend/src/components/TaskCard.tsx b/services/frontend/src/components/TaskCard.tsx new file mode 100644 index 0000000..9e95c98 --- /dev/null +++ b/services/frontend/src/components/TaskCard.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { formatDistanceToNow } from 'date-fns'; +import { ru } from 'date-fns/locale'; +import { Search, FileText, BookOpen, AlignLeft, ChevronRight } from 'lucide-react'; +import { clsx } from 'clsx'; +import { StatusBadge } from './StatusBadge'; +import type { Task } from '../types'; + +interface TaskCardProps { + task: Task; +} + +const TYPE_CONFIG = { + search: { + icon: Search, + label: 'Поиск источников', + color: 'text-blue-600', + bg: 'bg-blue-50', + }, + plagiarism: { + icon: FileText, + label: 'Проверка плагиата', + color: 'text-purple-600', + bg: 'bg-purple-50', + }, + gost: { + icon: BookOpen, + label: 'ГОСТ библиография', + color: 'text-emerald-600', + bg: 'bg-emerald-50', + }, + summarize: { + icon: AlignLeft, + label: 'Краткое изложение', + color: 'text-orange-600', + bg: 'bg-orange-50', + }, +}; + +function getTaskSummary(task: Task): string { + if (task.status === 'queued') { + const pos = task.queue_position; + return pos ? `Позиция в очереди: ${pos}` : 'Ожидает выполнения'; + } + if (task.status === 'processing') return 'Выполняется...'; + if (task.status === 'failed') return task.error || 'Произошла ошибка'; + + const result = task.result as Record | undefined; + if (!result) return 'Результат готов'; + + if (task.type === 'search') { + const total = result.total as number; + return `Найдено ${total} источников`; + } + if (task.type === 'plagiarism') { + const sim = result.overall_similarity as number; + return `Схожесть: ${sim?.toFixed(1)}%`; + } + if (task.type === 'gost') { + const count = (result.bibliography as unknown[])?.length; + return `${count} записей в библиографии`; + } + return 'Результат готов'; +} + +export function TaskCard({ task }: TaskCardProps) { + const config = TYPE_CONFIG[task.type] || TYPE_CONFIG.search; + const Icon = config.icon; + const summary = getTaskSummary(task); + + return ( + +
+ {/* Иконка типа */} +
+ +
+ + {/* Контент */} +
+
+ {config.label} + +
+

{summary}

+

+ {formatDistanceToNow(new Date(task.created_at), { + addSuffix: true, + locale: ru, + })} +

+
+ + {/* Стрелка */} + +
+ + ); +} diff --git a/services/frontend/src/hooks/useTaskPolling.ts b/services/frontend/src/hooks/useTaskPolling.ts new file mode 100644 index 0000000..5e9027d --- /dev/null +++ b/services/frontend/src/hooks/useTaskPolling.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import type { Task } from '../types'; + +/** + * Хук для WebSocket подключения к задаче. + * Обновляет кэш React Query при получении обновления статуса. + */ +export function useTaskWebSocket(taskId: string | undefined, enabled: boolean) { + const queryClient = useQueryClient(); + const wsRef = useRef(null); + + useEffect(() => { + if (!taskId || !enabled) return; + + const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws/tasks/${taskId}`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data === 'pong') return; + + // Обновить кэш задачи + queryClient.setQueryData(['task', taskId], (old) => { + if (!old) return old; + return { ...old, ...data }; + }); + } catch (e) { + console.warn('Ошибка парсинга WebSocket сообщения:', e); + } + }; + + ws.onerror = () => { + console.warn(`WebSocket ошибка для задачи ${taskId}`); + }; + + // Keepalive ping каждые 30 секунд + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send('ping'); + } + }, 30000); + + return () => { + clearInterval(pingInterval); + ws.close(); + wsRef.current = null; + }; + }, [taskId, enabled, queryClient]); +} diff --git a/services/frontend/src/index.css b/services/frontend/src/index.css new file mode 100644 index 0000000..42c1443 --- /dev/null +++ b/services/frontend/src/index.css @@ -0,0 +1,10 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + } +} diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx new file mode 100644 index 0000000..46107eb --- /dev/null +++ b/services/frontend/src/main.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Toaster } from 'react-hot-toast'; + +import { Layout } from './components/Layout'; +import { Home } from './pages/Home'; +import { Search } from './pages/Search'; +import { Check } from './pages/Check'; +import { Bibliography } from './pages/Bibliography'; +import { Cabinet } from './pages/Cabinet'; +import { Task } from './pages/Task'; +import { Pricing } from './pages/Pricing'; +import { Login } from './pages/Login'; +import { Register } from './pages/Register'; + +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30000, + retry: 1, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + +); diff --git a/services/frontend/src/pages/Bibliography.tsx b/services/frontend/src/pages/Bibliography.tsx new file mode 100644 index 0000000..20b0064 --- /dev/null +++ b/services/frontend/src/pages/Bibliography.tsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { GostCitation } from '../components/GostCitation'; +import { useBibliographyStore } from '../store/bibliography'; +import { useAuthStore } from '../store/auth'; +import { api } from '../api/client'; +import type { GostResultData } from '../types'; + +export function Bibliography() { + const { sources, removeSource, clearSources } = useBibliographyStore(); + const { isAuthenticated } = useAuthStore(); + const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1'); + const [formattedResult, setFormattedResult] = useState(null); + + const formatMutation = useMutation({ + mutationFn: async () => { + // Создать задачу GOST форматирования + const task = await api.post('/tasks/', { + type: 'gost', + input_data: { + doc_ids: sources.map((s) => s.id), + style, + }, + }); + const taskId = task.data.id; + + // Диспатчить задачу + await api.post('/gost/format', { + task_id: taskId, + doc_ids: sources.map((s) => s.id), + style, + }); + + // Простое форматирование прямо на клиенте (используем ГОСТ-цитаты из источников) + return { + bibliography: sources.map((s, i) => ({ + number: i + 1, + citation: s.gost_citation, + doc_id: s.id, + })), + style, + total: sources.length, + } as GostResultData; + }, + onSuccess: (data) => { + setFormattedResult(data); + toast.success('Библиография отформатирована'); + }, + onError: () => { + // При ошибке используем локальные ГОСТ-цитаты + setFormattedResult({ + bibliography: sources.map((s, i) => ({ + number: i + 1, + citation: s.gost_citation, + doc_id: s.id, + })), + style, + total: sources.length, + }); + }, + }); + + const handleFormat = () => { + if (sources.length === 0) { + toast.error('Добавьте источники в библиографию'); + return; + } + formatMutation.mutate(); + }; + + const handleCopyAll = () => { + if (!formattedResult) return; + const text = formattedResult.bibliography + .map((e) => `${e.number}. ${e.citation}`) + .join('\n'); + navigator.clipboard.writeText(text).then(() => { + toast.success('Список литературы скопирован'); + }); + }; + + return ( +
+
+

Список литературы

+

+ Добавляйте источники из результатов поиска и форматируйте библиографию по ГОСТ. +

+
+ + {/* Источники в списке */} + {sources.length > 0 ? ( +
+
+ + Источников: {sources.length} + + +
+
+ {sources.map((source) => ( +
+
+

{source.title}

+

+ {source.authors.slice(0, 2).map((a) => a.last_name).join(', ')} + {source.year && ` · ${source.year}`} +

+
+ +
+ ))} +
+
+ ) : ( +
+ +

+ Нажмите «В библиографию» на любом источнике из результатов поиска +

+
+ )} + + {/* Настройки форматирования */} + {sources.length > 0 && ( +
+
+ + +
+ + +
+ )} + + {/* Результат */} + {formattedResult && ( +
+
+ + Список литературы · ГОСТ {style} + + +
+
+ {formattedResult.bibliography.map((entry) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/services/frontend/src/pages/Cabinet.tsx b/services/frontend/src/pages/Cabinet.tsx new file mode 100644 index 0000000..4e064a0 --- /dev/null +++ b/services/frontend/src/pages/Cabinet.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Crown, Search, Shield, BookOpen } from 'lucide-react'; +import { TaskCard } from '../components/TaskCard'; +import { tasksApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; +import { PLAN_LIMITS, type Task } from '../types'; + +const PLAN_BADGE_STYLES = { + free: 'bg-gray-100 text-gray-600', + student: 'bg-blue-100 text-blue-700', + premium: 'bg-purple-100 text-purple-700', + science: 'bg-amber-100 text-amber-700', +}; + +export function Cabinet() { + const { isAuthenticated, user } = useAuthStore(); + + if (!isAuthenticated) { + return ; + } + + const { data: tasks, isLoading } = useQuery({ + queryKey: ['tasks'], + queryFn: () => tasksApi.list().then((r) => r.data as Task[]), + refetchInterval: 10000, + }); + + const plan = user?.plan || 'free'; + const planInfo = PLAN_LIMITS[plan as keyof typeof PLAN_LIMITS]; + + return ( +
+ {/* Профиль */} +
+
+
+ + {user?.name?.[0]?.toUpperCase() || 'U'} + +
+
+
+

{user?.name}

+ + {planInfo.name} + +
+

{user?.email}

+
+ +
+
+ + {/* Лимиты */} +
+ {[ + { + icon: Search, + label: 'Поисков/день', + value: planInfo.search_per_day, + color: 'text-blue-600', + bg: 'bg-blue-50', + }, + { + icon: Shield, + label: 'Проверок/мес', + value: planInfo.plagiarism_per_month, + color: 'text-purple-600', + bg: 'bg-purple-50', + }, + { + icon: BookOpen, + label: 'Изложений/мес', + value: planInfo.summarize_per_month, + color: 'text-emerald-600', + bg: 'bg-emerald-50', + }, + { + icon: Crown, + label: 'Одновременно', + value: planInfo.concurrent, + color: 'text-amber-600', + bg: 'bg-amber-50', + }, + ].map(({ icon: Icon, label, value, color, bg }) => ( +
+
+ +
+
+ {value === null ? '∞' : value} +
+
{label}
+
+ ))} +
+ + {/* Задачи */} +
+

+ Мои задачи {tasks && `(${tasks.length})`} +

+ + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {tasks && tasks.length > 0 && ( +
+ {tasks.map((task) => ( + + ))} +
+ )} + + {tasks && tasks.length === 0 && ( +
+

Задач пока нет. Начните с поиска источников!

+
+ )} +
+
+ ); +} diff --git a/services/frontend/src/pages/Check.tsx b/services/frontend/src/pages/Check.tsx new file mode 100644 index 0000000..205f80a --- /dev/null +++ b/services/frontend/src/pages/Check.tsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { Shield, LayoutDashboard } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { DropZone } from '../components/DropZone'; +import { documentsApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; +import type { Task } from '../types'; + +export function Check() { + const { isAuthenticated } = useAuthStore(); + const navigate = useNavigate(); + const [file, setFile] = useState(null); + const [submittedTask, setSubmittedTask] = useState(null); + + const upload = useMutation({ + mutationFn: () => documentsApi.uploadForCheck(file!), + onSuccess: (response) => { + const task: Task = response.data; + setSubmittedTask(task); + toast.success('Файл загружен, проверка начата!'); + }, + onError: (error: any) => { + const msg = error.response?.data?.detail || 'Ошибка загрузки'; + if (error.response?.status === 401) { + toast.error('Войдите для проверки плагиата'); + navigate('/login'); + } else { + toast.error(msg); + } + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!file) return; + if (!isAuthenticated) { + toast.error('Необходима авторизация'); + navigate('/login'); + return; + } + upload.mutate(); + }; + + if (submittedTask) { + return ( +
+
+
+ +
+

Проверка запущена

+

+ Файл {(submittedTask.input_data as any).filename} передан на проверку. +

+

+ Вы получите email когда проверка завершится. Обычно это занимает 1-3 минуты. +

+
+ + Следить за прогрессом + + + + Личный кабинет + +
+
+
+ ); + } + + return ( +
+
+

Проверка плагиата

+

+ Загрузите документ для проверки на 4 уровнях: точные совпадения, нечёткий поиск, + семантика и LLM-анализ парафраза. +

+
+ +
+ setFile(null)} + /> + + {!isAuthenticated && ( +
+ Войдите или{' '} + зарегистрируйтесь{' '} + для проверки плагиата +
+ )} + + + + + {/* Описание уровней */} +
+

4 уровня проверки:

+
+
1. Winnowing + MinHash — точные и нечёткие совпадения (~мс)
+
2. n-граммы + Jaccard — перестановки слов (~сек)
+
3. FAISS GPU cosine — семантическая близость (~мс)
+
4. Ollama Llama3 — анализ парафраза (~2 сек)
+
+
+
+ ); +} diff --git a/services/frontend/src/pages/Home.tsx b/services/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..5ace1a7 --- /dev/null +++ b/services/frontend/src/pages/Home.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Search, Shield, BookOpen, Zap } from 'lucide-react'; +import { SearchBar } from '../components/SearchBar'; + +const FEATURES = [ + { + icon: Search, + title: 'Семантический поиск', + description: 'Поиск по миллионам статей через FAISS GPU + Elasticsearch BM25. Находит близкие по смыслу источники, а не только по ключевым словам.', + color: 'text-blue-600', + bg: 'bg-blue-50', + }, + { + icon: Shield, + title: 'Проверка плагиата', + description: '4 уровня проверки: Winnowing, MinHash, семантическое сравнение, LLM-анализ парафраза. Обнаружит даже перефразированный плагиат.', + color: 'text-purple-600', + bg: 'bg-purple-50', + }, + { + icon: BookOpen, + title: 'ГОСТ-библиография', + description: 'Автоматическое форматирование по ГОСТ 7.1-2003 и ГОСТ Р 7.0.5-2008. Правильный порядок и разделители.', + color: 'text-emerald-600', + bg: 'bg-emerald-50', + }, + { + icon: Zap, + title: 'Асинхронно', + description: 'Закройте браузер — получите email, когда результат готов. Очередь задач, real-time обновления через WebSocket.', + color: 'text-orange-600', + bg: 'bg-orange-50', + }, +]; + +export function Home() { + const navigate = useNavigate(); + + const handleSearch = (query: string) => { + navigate(`/search?q=${encodeURIComponent(query)}`); + }; + + return ( +
+ {/* Hero */} +
+

+ Академический помощник +

+

+ Поиск научных источников, проверка плагиата и ГОСТ-библиография — всё в одном месте. + Введите тему и система найдёт релевантные источники автоматически. +

+ +
+ +
+ +

+ Например: «нейронные сети в обработке естественного языка» или «квантовые вычисления алгоритмы» +

+
+ + {/* Фичи */} +
+
+ {FEATURES.map(({ icon: Icon, title, description, color, bg }) => ( +
+
+ +
+

{title}

+

{description}

+
+ ))} +
+
+ + {/* Источники */} +
+

Источники данных

+

Поиск ведётся по открытым академическим базам данных

+
+ {['OpenAlex', 'КиберЛенинка', 'arXiv', 'Wikipedia RU', 'Wikipedia EN'].map((name) => ( + + {name} + + ))} +
+
+
+ ); +} diff --git a/services/frontend/src/pages/Login.tsx b/services/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..db6be22 --- /dev/null +++ b/services/frontend/src/pages/Login.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { GraduationCap } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { authApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; +import type { TokenResponse } from '../types'; + +export function Login() { + const navigate = useNavigate(); + const { setAuth } = useAuthStore(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const login = useMutation({ + mutationFn: () => authApi.login({ email, password }), + onSuccess: (response) => { + const data: TokenResponse = response.data; + setAuth(data.user, data.access_token); + toast.success(`Добро пожаловать, ${data.user.name}!`); + navigate('/cabinet'); + }, + onError: (error: any) => { + const msg = error.response?.data?.detail || 'Ошибка входа'; + toast.error(msg); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + login.mutate(); + }; + + return ( +
+
+
+
+ +
+
+

Вход

+

Войдите в свой аккаунт

+ +
+
+ + setEmail(e.target.value)} + required + placeholder="ivan@example.com" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + placeholder="••••••••" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ + +
+ +

+ Нет аккаунта?{' '} + + Зарегистрироваться + +

+
+
+ ); +} diff --git a/services/frontend/src/pages/Pricing.tsx b/services/frontend/src/pages/Pricing.tsx new file mode 100644 index 0000000..1450e7e --- /dev/null +++ b/services/frontend/src/pages/Pricing.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { Check } from 'lucide-react'; +import { clsx } from 'clsx'; +import { PLAN_LIMITS } from '../types'; + +const PLAN_ORDER = ['free', 'student', 'premium', 'science'] as const; + +const PLAN_FEATURES = { + free: [ + '10 поисков в день', + '3 краткие изложения в месяц', + '1 проверка плагиата в месяц', + '1 задача одновременно', + 'ГОСТ библиография', + ], + student: [ + 'Безлимитный поиск', + '30 кратких изложений в месяц', + '10 проверок плагиата в месяц', + '2 задачи одновременно', + 'ГОСТ библиография', + 'Email уведомления', + ], + premium: [ + 'Безлимитный поиск', + 'Безлимитные изложения', + '50 проверок плагиата в месяц', + '5 задач одновременно', + 'ГОСТ библиография', + 'Приоритетная очередь', + ], + science: [ + 'Безлимитный поиск', + 'Безлимитные изложения', + 'Безлимитные проверки плагиата', + '10 задач одновременно', + 'ГОСТ библиография', + 'Максимальный приоритет', + 'API доступ', + ], +}; + +const POPULAR_PLAN = 'student'; + +export function Pricing() { + return ( +
+
+

Тарифные планы

+

+ Начните бесплатно. Обновите план для расширенных возможностей. +

+
+ +
+ {PLAN_ORDER.map((planKey) => { + const plan = PLAN_LIMITS[planKey]; + const features = PLAN_FEATURES[planKey]; + const isPopular = planKey === POPULAR_PLAN; + + return ( +
+ {isPopular && ( +
+ + Популярный + +
+ )} + +
+

{plan.name}

+
+ {plan.price === 0 ? ( + Бесплатно + ) : ( + <> + {plan.price}₽ + /мес + + )} +
+
+ +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + +
+ ); + })} +
+ +
+ Оплата через российские системы. При вопросах пишите на noreply@jze9.ru +
+
+ ); +} diff --git a/services/frontend/src/pages/Register.tsx b/services/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..8d9bf37 --- /dev/null +++ b/services/frontend/src/pages/Register.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { GraduationCap } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { authApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; +import type { TokenResponse } from '../types'; + +export function Register() { + const navigate = useNavigate(); + const { setAuth } = useAuthStore(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const register = useMutation({ + mutationFn: () => authApi.register({ name, email, password }), + onSuccess: (response) => { + const data: TokenResponse = response.data; + setAuth(data.user, data.access_token); + toast.success('Аккаунт создан! Проверьте email для подтверждения.'); + navigate('/cabinet'); + }, + onError: (error: any) => { + const msg = error.response?.data?.detail || 'Ошибка регистрации'; + toast.error(msg); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + register.mutate(); + }; + + return ( +
+
+
+
+ +
+
+

Регистрация

+

Создайте аккаунт, это бесплатно

+ +
+
+ + setName(e.target.value)} + required + minLength={2} + placeholder="Иван Иванов" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ +
+ + setEmail(e.target.value)} + required + placeholder="ivan@example.com" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + placeholder="Минимум 8 символов" + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ + +
+ +

+ Уже есть аккаунт?{' '} + + Войти + +

+
+
+ ); +} diff --git a/services/frontend/src/pages/Search.tsx b/services/frontend/src/pages/Search.tsx new file mode 100644 index 0000000..26e2000 --- /dev/null +++ b/services/frontend/src/pages/Search.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { Filter, Loader2 } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { SearchBar } from '../components/SearchBar'; +import { SourceCard } from '../components/SourceCard'; +import { StatusBadge } from '../components/StatusBadge'; +import { searchApi, tasksApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; +import { useTaskWebSocket } from '../hooks/useTaskPolling'; +import type { Task, SearchResultData } from '../types'; + +export function Search() { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const { isAuthenticated } = useAuthStore(); + + const query = searchParams.get('q') || ''; + const [taskId, setTaskId] = useState(null); + const [lang, setLang] = useState(''); + const [yearFrom, setYearFrom] = useState(''); + const [yearTo, setYearTo] = useState(''); + + // Создать задачу поиска + const createSearch = useMutation({ + mutationFn: searchApi.create, + onSuccess: (response) => { + const task: Task = response.data; + setTaskId(task.id); + }, + onError: (error: any) => { + const msg = error.response?.data?.detail || 'Ошибка поиска'; + if (error.response?.status === 401) { + toast.error('Войдите, чтобы выполнять поиск'); + navigate('/login'); + } else { + toast.error(msg); + } + }, + }); + + // Поллинг задачи + const { + data: task, + isLoading: isPolling, + } = useQuery({ + queryKey: ['task', taskId], + queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task), + enabled: !!taskId, + refetchInterval: (data) => { + if (!data) return 3000; + if (data.status === 'done' || data.status === 'failed') return false; + return 3000; + }, + }); + + // WebSocket для real-time обновлений + useTaskWebSocket( + taskId ?? undefined, + !!taskId && task?.status !== 'done' && task?.status !== 'failed' + ); + + // Запустить поиск при изменении query + useEffect(() => { + if (query && query.length >= 3 && isAuthenticated) { + createSearch.mutate({ + query, + lang: lang || undefined, + year_from: yearFrom ? parseInt(yearFrom) : undefined, + year_to: yearTo ? parseInt(yearTo) : undefined, + }); + } + }, [query]); + + const handleSearch = (newQuery: string) => { + setTaskId(null); + setSearchParams({ q: newQuery }); + }; + + const result = task?.result as SearchResultData | undefined; + const isLoading = createSearch.isPending || (!!taskId && task?.status === 'queued') || task?.status === 'processing'; + + return ( +
+ {/* Поисковая строка */} + + +
+ {/* Боковая панель фильтров */} + + + {/* Результаты */} +
+ {/* Статус задачи */} + {task && task.status !== 'done' && ( +
+
+ + +
+ {task.queue_position && ( +

Позиция в очереди: {task.queue_position}

+ )} + {task.eta_seconds && ( +

Ожидаемое время: ~{task.eta_seconds} сек

+ )} +
+ )} + + {/* Результаты поиска */} + {task?.status === 'done' && result && ( +
+

+ Найдено: {result.total} источников +

+ {result.sources.map((source) => ( + + ))} +
+ )} + + {/* Ошибка */} + {task?.status === 'failed' && ( +
+

Ошибка поиска

+

{task.error}

+
+ )} + + {/* Пустое состояние */} + {!query && !task && ( +
+

Введите запрос для поиска источников

+
+ )} + + {/* Не авторизован */} + {query && !isAuthenticated && ( +
+

Необходима авторизация

+

Войдите или зарегистрируйтесь для выполнения поиска

+
+ )} +
+
+
+ ); +} diff --git a/services/frontend/src/pages/Task.tsx b/services/frontend/src/pages/Task.tsx new file mode 100644 index 0000000..6d6eacd --- /dev/null +++ b/services/frontend/src/pages/Task.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { useParams, Navigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Loader2, ArrowLeft } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { StatusBadge } from '../components/StatusBadge'; +import { SourceCard } from '../components/SourceCard'; +import { PlagiarismReport } from '../components/PlagiarismReport'; +import { GostCitation } from '../components/GostCitation'; +import { tasksApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; +import { useTaskWebSocket } from '../hooks/useTaskPolling'; +import type { Task as TaskType, SearchResultData, PlagiarismResultData, GostResultData } from '../types'; + +export function Task() { + const { taskId } = useParams<{ taskId: string }>(); + const { isAuthenticated } = useAuthStore(); + + if (!isAuthenticated) return ; + if (!taskId) return ; + + const { data: task, isLoading } = useQuery({ + queryKey: ['task', taskId], + queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType), + refetchInterval: (data) => { + if (!data) return 3000; + return (data.status === 'done' || data.status === 'failed') ? false : 3000; + }, + }); + + useTaskWebSocket(taskId, !!task && task.status !== 'done' && task.status !== 'failed'); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!task) { + return ; + } + + return ( +
+ {/* Шапка */} +
+ + + +
+

+ {{ + search: 'Поиск источников', + plagiarism: 'Проверка плагиата', + gost: 'ГОСТ библиография', + summarize: 'Краткое изложение', + }[task.type] || task.type} +

+
+ + {task.id} +
+
+
+ + {/* В процессе */} + {(task.status === 'queued' || task.status === 'processing') && ( +
+ +

+ {task.status === 'queued' ? 'Задача в очереди...' : 'Выполняется...'} +

+ {task.queue_position && ( +

Позиция: {task.queue_position}

+ )} +

Вы получите email когда задача завершится

+
+ )} + + {/* Ошибка */} + {task.status === 'failed' && ( +
+

Задача завершилась с ошибкой

+

{task.error}

+
+ )} + + {/* Результаты поиска */} + {task.status === 'done' && task.type === 'search' && task.result && ( +
+

+ Запрос: «{(task.input_data as any).query}» + {' · '}{(task.result as SearchResultData).total} источников +

+ {(task.result as SearchResultData).sources.map((source) => ( + + ))} +
+ )} + + {/* Отчёт о плагиате */} + {task.status === 'done' && task.type === 'plagiarism' && task.result && ( +
+

+ Файл: {(task.input_data as any).filename} +

+ +
+ )} + + {/* ГОСТ библиография */} + {task.status === 'done' && task.type === 'gost' && task.result && ( +
+
+

+ Список литературы (ГОСТ {(task.result as GostResultData).style}-2003) +

+ +
+
+ {(task.result as GostResultData).bibliography.map((entry) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/services/frontend/src/store/auth.ts b/services/frontend/src/store/auth.ts new file mode 100644 index 0000000..eaa263a --- /dev/null +++ b/services/frontend/src/store/auth.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { User } from '../types'; + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + setAuth: (user: User, token: string) => void; + updateUser: (user: Partial) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + token: null, + isAuthenticated: false, + + setAuth: (user, token) => + set({ user, token, isAuthenticated: true }), + + updateUser: (updates) => { + const current = get().user; + if (current) { + set({ user: { ...current, ...updates } }); + } + }, + + logout: () => + set({ user: null, token: null, isAuthenticated: false }), + }), + { + name: 'auth-storage', + // Не сохранять методы в localStorage, только данные + partialize: (state) => ({ + user: state.user, + token: state.token, + isAuthenticated: state.isAuthenticated, + }), + } + ) +); diff --git a/services/frontend/src/store/bibliography.ts b/services/frontend/src/store/bibliography.ts new file mode 100644 index 0000000..9ba00b1 --- /dev/null +++ b/services/frontend/src/store/bibliography.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { SearchSource } from '../types'; + +interface BibliographyState { + sources: SearchSource[]; + addSource: (source: SearchSource) => void; + removeSource: (id: number) => void; + clearSources: () => void; + hasSource: (id: number) => boolean; +} + +export const useBibliographyStore = create()( + persist( + (set, get) => ({ + sources: [], + + addSource: (source) => { + const existing = get().sources.find((s) => s.id === source.id); + if (!existing) { + set((state) => ({ sources: [...state.sources, source] })); + } + }, + + removeSource: (id) => { + set((state) => ({ + sources: state.sources.filter((s) => s.id !== id), + })); + }, + + clearSources: () => set({ sources: [] }), + + hasSource: (id) => get().sources.some((s) => s.id === id), + }), + { + name: 'bibliography-storage', + } + ) +); diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts new file mode 100644 index 0000000..468208c --- /dev/null +++ b/services/frontend/src/types/index.ts @@ -0,0 +1,179 @@ +// ─── Типы данных для Академического помощника ───────────────────────────────── + +export type TaskStatus = 'queued' | 'processing' | 'done' | 'failed'; +export type TaskType = 'search' | 'plagiarism' | 'summarize' | 'gost'; +export type UserPlan = 'free' | 'student' | 'premium' | 'science'; + +// ─── Пользователь ───────────────────────────────────────────────────────────── + +export interface User { + id: number; + email: string; + name: string; + plan: UserPlan; + is_verified: boolean; +} + +// ─── Источник в результатах поиска ──────────────────────────────────────────── + +export interface AuthorInfo { + last_name: string; + first_name?: string; + initials?: string; +} + +export interface SearchSource { + id: number; + title: string; + authors: AuthorInfo[]; + year: number | null; + journal: string | null; + abstract: string | null; + url: string | null; + doi: string | null; + relevance_score: number; + gost_citation: string; + source_db: string; +} + +export interface SearchResultData { + sources: SearchSource[]; + total: number; + query: string; +} + +// ─── Отчёт о плагиате ───────────────────────────────────────────────────────── + +export interface PlagiarismMatch { + fragment: string; + position_start: number; + position_end: number; + similarity: number; + method: 'exact' | 'fuzzy' | 'semantic+llm'; + confidence?: number; + reason?: string; + source_title: string; + source_url: string | null; + source_db: string; +} + +export interface PlagiarismResultData { + overall_similarity: number; + matches: PlagiarismMatch[]; + total_fragments: number; + flagged_fragments: number; + by_method?: { + exact: number; + fuzzy: number; + semantic_llm: number; + }; +} + +// ─── Библиография ───────────────────────────────────────────────────────────── + +export interface BibliographyEntry { + number: number; + citation: string; + doc_id: number; +} + +export interface GostResultData { + bibliography: BibliographyEntry[]; + style: string; + total: number; +} + +// ─── Задача ─────────────────────────────────────────────────────────────────── + +export type TaskResult = SearchResultData | PlagiarismResultData | GostResultData | null; + +export interface Task { + id: string; + type: TaskType; + status: TaskStatus; + queue_position?: number; + eta_seconds?: number; + input_data: Record; + result?: TaskResult; + error?: string; + created_at: string; +} + +// ─── API ответы ─────────────────────────────────────────────────────────────── + +export interface TokenResponse { + access_token: string; + token_type: string; + user: User; +} + +export interface ApiError { + detail: string; +} + +// ─── Запросы ────────────────────────────────────────────────────────────────── + +export interface SearchRequest { + query: string; + lang?: string; + year_from?: number; + year_to?: number; + category?: string; +} + +export interface RegisterRequest { + email: string; + password: string; + name: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +// ─── Лимиты тарифных планов ─────────────────────────────────────────────────── + +export interface PlanLimits { + name: string; + price: number; + search_per_day: number | null; + summarize_per_month: number | null; + plagiarism_per_month: number | null; + concurrent: number; +} + +export const PLAN_LIMITS: Record = { + free: { + name: 'Бесплатный', + price: 0, + search_per_day: 10, + summarize_per_month: 3, + plagiarism_per_month: 1, + concurrent: 1, + }, + student: { + name: 'Студенческий', + price: 199, + search_per_day: null, + summarize_per_month: 30, + plagiarism_per_month: 10, + concurrent: 2, + }, + premium: { + name: 'Премиум', + price: 499, + search_per_day: null, + summarize_per_month: null, + plagiarism_per_month: 50, + concurrent: 5, + }, + science: { + name: 'Научный', + price: 999, + search_per_day: null, + summarize_per_month: null, + plagiarism_per_month: null, + concurrent: 10, + }, +}; diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js new file mode 100644 index 0000000..e18d1aa --- /dev/null +++ b/services/frontend/tailwind.config.js @@ -0,0 +1,26 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + brand: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + }, + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + }, + }, + plugins: [], +}; diff --git a/services/frontend/tsconfig.json b/services/frontend/tsconfig.json new file mode 100644 index 0000000..c20738e --- /dev/null +++ b/services/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/services/frontend/tsconfig.node.json b/services/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/services/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/services/frontend/vite.config.ts b/services/frontend/vite.config.ts new file mode 100644 index 0000000..a4dd028 --- /dev/null +++ b/services/frontend/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:8000', + ws: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + }, +}); diff --git a/services/worker-gost/Dockerfile b/services/worker-gost/Dockerfile new file mode 100644 index 0000000..eaa969a --- /dev/null +++ b/services/worker-gost/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["celery", "-A", "app.celery_app", "worker", "-Q", "queue.gost", "-c", "8", "-n", "gost@%h", "--loglevel=info"] diff --git a/services/worker-gost/app/__init__.py b/services/worker-gost/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-gost/app/celery_app.py b/services/worker-gost/app/celery_app.py new file mode 100644 index 0000000..a48ec88 --- /dev/null +++ b/services/worker-gost/app/celery_app.py @@ -0,0 +1,27 @@ +"""Celery приложение GOST воркера.""" + +from celery import Celery + +from app.config import settings + +celery_app = Celery( + "worker_gost", + broker=settings.RABBITMQ_URL, + backend=settings.REDIS_URL, + include=["app.tasks.gost"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Europe/Moscow", + enable_utc=True, + task_track_started=True, + task_routes={ + "gost.*": {"queue": "queue.gost"}, + "notify.*": {"queue": "queue.notify"}, + }, + task_acks_late=True, + result_expires=86400, +) diff --git a/services/worker-gost/app/config.py b/services/worker-gost/app/config.py new file mode 100644 index 0000000..d793f58 --- /dev/null +++ b/services/worker-gost/app/config.py @@ -0,0 +1,33 @@ +"""Конфигурация GOST воркера.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + POSTGRES_HOST: str = "postgres" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "antiplagiator" + POSTGRES_USER: str = "antiplagiator" + POSTGRES_PASSWORD: str = "changeme" + + REDIS_URL: str = "redis://redis:6379/0" + RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/" + + ENVIRONMENT: str = "development" + DEBUG: bool = False + + @property + def database_url_sync(self) -> str: + return ( + f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + +settings = Settings() diff --git a/services/worker-gost/app/db.py b/services/worker-gost/app/db.py new file mode 100644 index 0000000..0b45747 --- /dev/null +++ b/services/worker-gost/app/db.py @@ -0,0 +1,32 @@ +"""Подключение к PostgreSQL для gost-воркера.""" + +from contextlib import contextmanager +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import settings + +engine = create_engine( + settings.database_url_sync, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +@contextmanager +def db_session() -> Generator[Session, None, None]: + """Контекстный менеджер для сессии БД.""" + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/services/worker-gost/app/formatters/__init__.py b/services/worker-gost/app/formatters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-gost/app/formatters/gost_7_0_5.py b/services/worker-gost/app/formatters/gost_7_0_5.py new file mode 100644 index 0000000..d1a7be3 --- /dev/null +++ b/services/worker-gost/app/formatters/gost_7_0_5.py @@ -0,0 +1,57 @@ +"""ГОСТ Р 7.0.5-2008 — Краткая библиографическая ссылка. + +Используется для ссылок внутри текста: [Автор, год] +""" + + +class GOST705Formatter: + """Форматтер кратких библиографических ссылок по ГОСТ Р 7.0.5-2008.""" + + def format_short(self, doc: dict) -> str: + """ + Форматировать краткую ссылку вида [Автор, год]. + + Args: + doc: Словарь с метаданными документа + + Returns: + Форматированная краткая ссылка, например [Иванов, 2023] + """ + authors = doc.get("authors", []) + year = doc.get("year", "б. г.") + + if authors: + first_author = authors[0] + last_name = first_author.get("last_name", "") + if last_name: + return f"[{last_name}, {year}]" + + # Если нет авторов — используем первые слова названия + title = doc.get("title", "") + if title: + short_title = " ".join(title.split()[:3]) + return f"[{short_title}..., {year}]" + + return f"[{year}]" + + def format_inline(self, doc: dict, page: str | None = None) -> str: + """ + Форматировать ссылку для включения в текст с опциональным номером страницы. + + Args: + doc: Словарь с метаданными + page: Номер страницы (опционально) + + Returns: + Например: [Иванов, 2023, с. 15] или [Иванов, 2023] + """ + base = self.format_short(doc) + if page: + # Вставить номер страницы перед закрывающей скобкой + return base[:-1] + f", с. {page}]" + return base + + +def format_short(doc: dict) -> str: + """Удобная функция для быстрого форматирования.""" + return GOST705Formatter().format_short(doc) diff --git a/services/worker-gost/app/formatters/gost_7_1.py b/services/worker-gost/app/formatters/gost_7_1.py new file mode 100644 index 0000000..d39235b --- /dev/null +++ b/services/worker-gost/app/formatters/gost_7_1.py @@ -0,0 +1,267 @@ +"""ГОСТ 7.1-2003 — Библиографическая запись. Библиографическое описание. + +Полная запись для списка литературы. + +Формат для статьи в журнале: + Автор(ы). Название // Журнал. — Год. — Т. X, № X. — С. X–X. + +Формат для книги: + Автор(ы). Название. — Город : Издательство, Год. — X с. + +Правила по ГОСТ 7.1-2003: +- До 3 авторов: перечислить всех +- 4+ авторов: первые 3 + "и др." (рус.) / "et al." (англ.) +- Разделитель смысловых частей: " — " +- Разделитель авторов: ", " +- Место публикации через "/" +""" + +import re + + +def _format_author(author: dict) -> str: + """ + Форматировать одного автора. + + Args: + author: dict с полями last_name, first_name, initials (опционально) + + Returns: + Строка вида "Иванов И. И." или "Иванов И." + """ + last_name = author.get("last_name", "").strip() + initials = author.get("initials", "").strip() + first_name = author.get("first_name", "").strip() + + if not last_name: + return "" + + if initials: + # Нормализовать инициалы: обеспечить точки + if not initials.endswith("."): + initials += "." + return f"{last_name} {initials}" + elif first_name: + # Извлечь инициалы из полного имени + parts = first_name.split() + inits = "".join(p[0].upper() + "." for p in parts if p) + return f"{last_name} {inits}" + else: + return last_name + + +def _format_authors(authors: list[dict], lang: str = "ru") -> str: + """ + Форматировать список авторов по правилам ГОСТ 7.1-2003. + + Args: + authors: Список словарей с авторами + lang: Язык для "и др." / "et al." + + Returns: + Строка с отформатированными авторами + """ + if not authors: + return "" + + et_al = "и др." if lang == "ru" else "et al." + formatted = [_format_author(a) for a in authors if a.get("last_name")] + formatted = [f for f in formatted if f] + + if not formatted: + return "" + + if len(formatted) <= 3: + return ", ".join(formatted) + else: + return ", ".join(formatted[:3]) + f", {et_al}" + + +class GOST71Formatter: + """Форматтер полных библиографических записей по ГОСТ 7.1-2003.""" + + def format_full(self, doc: dict) -> str: + """ + Форматировать полную библиографическую запись. + + Args: + doc: Словарь с полями документа + + Returns: + Отформатированная строка библиографической записи + """ + doc_type = self._detect_type(doc) + + if doc_type == "article": + return self._format_article(doc) + elif doc_type == "book": + return self._format_book(doc) + elif doc_type == "web": + return self._format_web(doc) + else: + return self._format_generic(doc) + + def _detect_type(self, doc: dict) -> str: + """Определить тип документа.""" + if doc.get("journal"): + return "article" + if doc.get("url") and not doc.get("journal"): + return "web" + return "generic" + + def _format_article(self, doc: dict) -> str: + """ + Форматировать статью в журнале. + + Формат: Автор(ы). Название // Журнал. — Год. — Т. X, № X. — С. X–X. + """ + lang = doc.get("lang", "ru") or "ru" + authors = doc.get("authors", []) + author_str = _format_authors(authors, lang=lang) + + title = (doc.get("title") or "").strip() + journal = (doc.get("journal") or "").strip() + year = doc.get("year", "") + volume = doc.get("volume", "") + issue = doc.get("issue", "") + pages = doc.get("pages", "") + doi = doc.get("doi", "") + + parts = [] + + # Авторы и название + if author_str: + parts.append(f"{author_str}. {title}") + else: + parts.append(title) + + # Место публикации + location = f"// {journal}" + if year: + location += f". — {year}" + if volume: + location += f". — Т. {volume}" + if issue: + location += f", № {issue}" + if pages: + location += f". — С. {pages}" + + parts.append(location) + + result = " ".join(parts) + + # DOI + if doi: + result += f". — DOI: {doi}" + + return result.strip() + + def _format_book(self, doc: dict) -> str: + """ + Форматировать книгу / монографию. + + Формат: Автор(ы). Название. — Город : Издательство, Год. — X с. + """ + lang = doc.get("lang", "ru") or "ru" + authors = doc.get("authors", []) + author_str = _format_authors(authors, lang=lang) + + title = (doc.get("title") or "").strip() + year = doc.get("year", "б. г.") + pages = doc.get("pages", "") + + parts = [] + + if author_str: + parts.append(f"{author_str}.") + + parts.append(f"{title}.") + parts.append(f"— {year}.") + + if pages: + # Предполагаем, что pages содержит количество страниц + parts.append(f"— {pages} с.") + + return " ".join(parts).strip() + + def _format_web(self, doc: dict) -> str: + """ + Форматировать электронный ресурс. + + Формат: Автор(ы). Название [Электронный ресурс]. — URL: ... (дата обращения: ...) + """ + lang = doc.get("lang", "ru") or "ru" + authors = doc.get("authors", []) + author_str = _format_authors(authors, lang=lang) + + title = (doc.get("title") or "").strip() + url = (doc.get("url") or "").strip() + year = doc.get("year", "") + + parts = [] + + if author_str: + parts.append(f"{author_str}.") + + # Для ГОСТ — обозначение электронного ресурса + parts.append(f"{title} [Электронный ресурс].") + + if year: + parts.append(f"— {year}.") + + if url: + parts.append(f"— URL: {url}") + + return " ".join(parts).strip() + + def _format_generic(self, doc: dict) -> str: + """Базовый форматтер для неопределённых типов.""" + lang = doc.get("lang", "ru") or "ru" + author_str = _format_authors(doc.get("authors", []), lang=lang) + title = (doc.get("title") or "").strip() + year = doc.get("year", "") + + result = "" + if author_str: + result += f"{author_str}. " + result += title + if year: + result += f". — {year}" + + return result.strip() + + +def _get_sort_key(doc: dict) -> str: + """ + Получить ключ сортировки для документа. + + Кириллица идёт перед латиницей (для русских традиций): + сначала русскоязычные источники, затем иностранные. + + Args: + doc: Словарь с метаданными + + Returns: + Строка для сортировки + """ + authors = doc.get("authors", []) + if authors: + last_name = authors[0].get("last_name", "") + else: + last_name = doc.get("title", "") + + if not last_name: + return "яяя" # В конец + + # Кириллица < латиница (традиция: русские источники первыми) + first_char = last_name[0].lower() + is_latin = ord(first_char) < 256 and first_char.isalpha() + + # Префикс для сортировки: 0 для кириллицы, 1 для латиницы + prefix = "1" if is_latin else "0" + return f"{prefix}{last_name.lower()}" + + +def format_full(doc: dict) -> str: + """Удобная функция для быстрого форматирования.""" + return GOST71Formatter().format_full(doc) diff --git a/services/worker-gost/app/tasks/__init__.py b/services/worker-gost/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-gost/app/tasks/gost.py b/services/worker-gost/app/tasks/gost.py new file mode 100644 index 0000000..88c0403 --- /dev/null +++ b/services/worker-gost/app/tasks/gost.py @@ -0,0 +1,142 @@ +"""Celery задача форматирования библиографии по ГОСТ.""" + +import logging +from typing import Any + +from celery.utils.log import get_task_logger +from sqlalchemy import select + +from app.celery_app import celery_app +from app.db import db_session +from app.formatters.gost_7_0_5 import GOST705Formatter +from app.formatters.gost_7_1 import GOST71Formatter, _get_sort_key + +logger = get_task_logger(__name__) + + +@celery_app.task( + name="gost.format_bibliography", + bind=True, + max_retries=3, + default_retry_delay=30, +) +def format_bibliography( + self, + task_id: str, + doc_ids: list[int], + style: str = "7.1", +) -> dict[str, Any]: + """ + Форматировать список литературы по ГОСТ для переданных doc_ids. + + Args: + task_id: ID задачи в PostgreSQL + doc_ids: Список ID документов из PostgreSQL + style: Стиль форматирования — "7.1" (полная) или "7.0.5" (краткая) + + Returns: + dict с отформатированной библиографией + """ + from app.models import Document, Task + + logger.info( + f"Форматирование библиографии для задачи {task_id!r}: " + f"{len(doc_ids)} документов, стиль ГОСТ {style}" + ) + + try: + with db_session() as session: + # Обновить статус + task = session.get(Task, task_id) + if task: + task.status = "processing" + session.commit() + + # Получить документы + docs = session.execute( + select(Document).where(Document.id.in_(doc_ids)) + ).scalars().all() + + if not docs: + raise ValueError(f"Документы не найдены: {doc_ids}") + + # Выбрать форматтер + formatter = GOST71Formatter() if style == "7.1" else GOST705Formatter() + + # Преобразовать ORM объекты в словари для форматирования + docs_dicts = [] + for doc in docs: + docs_dicts.append({ + "id": doc.id, + "title": doc.title, + "authors": doc.authors or [], + "year": doc.year, + "journal": doc.journal, + "volume": doc.volume, + "issue": doc.issue, + "pages": doc.pages, + "doi": doc.doi, + "url": doc.url, + "lang": doc.lang or "ru", + "source": doc.source, + }) + + # Сортировать: кириллица (рус. авторы) → латиница (иностр.) + sorted_docs = sorted(docs_dicts, key=lambda d: _get_sort_key(d)) + + bibliography = [] + for i, doc_dict in enumerate(sorted_docs, 1): + if style == "7.1": + citation = formatter.format_full(doc_dict) + else: + citation = formatter.format_short(doc_dict) + + bibliography.append({ + "number": i, + "citation": citation, + "doc_id": doc_dict["id"], + }) + + result = { + "bibliography": bibliography, + "style": style, + "total": len(bibliography), + } + + # Сохранить результат + task = session.get(Task, task_id) + if task: + task.result = result + task.status = "done" + task.queue_position = None + session.commit() + + logger.info( + f"Библиография сформирована для задачи {task_id!r}: " + f"{len(bibliography)} записей по ГОСТ {style}" + ) + + # Уведомить пользователя + celery_app.send_task( + "notify.send_task_done", + args=[task_id], + queue="queue.notify", + ) + + return result + + except Exception as exc: + logger.error(f"Ошибка при форматировании библиографии для задачи {task_id!r}: {exc}", exc_info=True) + + try: + from app.models import Task + with db_session() as session: + task = session.get(Task, task_id) + if task: + task.status = "failed" + task.error = str(exc) + session.commit() + except Exception as db_exc: + logger.error(f"Не удалось обновить статус задачи: {db_exc}") + + raise self.retry(exc=exc, countdown=30) diff --git a/services/worker-gost/requirements.txt b/services/worker-gost/requirements.txt new file mode 100644 index 0000000..aea779a --- /dev/null +++ b/services/worker-gost/requirements.txt @@ -0,0 +1,5 @@ +celery==5.4.0 +redis==5.0.4 +sqlalchemy==2.0.30 +psycopg2-binary==2.9.9 +pydantic-settings==2.2.1 diff --git a/services/worker-gpu/Dockerfile b/services/worker-gpu/Dockerfile new file mode 100644 index 0000000..04db235 --- /dev/null +++ b/services/worker-gpu/Dockerfile @@ -0,0 +1,30 @@ +FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04 + +# Установить Python 3.11 +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3.11 \ + python3.11-dev \ + python3-pip \ + python3.11-distutils \ + gcc \ + g++ \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Сделать python3.11 дефолтным +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 && \ + update-alternatives --install /usr/bin/python python python3.11 1 && \ + python3.11 -m pip install --upgrade pip + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Директория для FAISS индекса (монтируется как volume) +RUN mkdir -p /data/index + +CMD ["celery", "-A", "app.celery_app", "worker", "-Q", "queue.gpu", "-c", "1", "-n", "gpu@%h", "--loglevel=info"] diff --git a/services/worker-gpu/app/__init__.py b/services/worker-gpu/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-gpu/app/celery_app.py b/services/worker-gpu/app/celery_app.py new file mode 100644 index 0000000..3630c43 --- /dev/null +++ b/services/worker-gpu/app/celery_app.py @@ -0,0 +1,38 @@ +"""Celery приложение GPU воркера.""" + +from celery import Celery +from celery.utils.log import get_task_logger + +from app.config import settings + +celery_app = Celery( + "worker_gpu", + broker=settings.RABBITMQ_URL, + backend=settings.REDIS_URL, + include=["app.tasks.search", "app.tasks.plagiarism"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Europe/Moscow", + enable_utc=True, + task_track_started=True, + # Маршрутизация + task_routes={ + "gpu.*": {"queue": "queue.gpu"}, + "index.*": {"queue": "queue.index"}, + "notify.*": {"queue": "queue.notify"}, + "gost.*": {"queue": "queue.gost"}, + }, + # Надёжность + task_acks_late=True, + task_reject_on_worker_lost=True, + # GPU воркер — только 1 процесс + worker_concurrency=1, + # Результаты хранить 24 часа + result_expires=86400, +) + +logger = get_task_logger(__name__) diff --git a/services/worker-gpu/app/config.py b/services/worker-gpu/app/config.py new file mode 100644 index 0000000..9d256ca --- /dev/null +++ b/services/worker-gpu/app/config.py @@ -0,0 +1,64 @@ +"""Конфигурация GPU воркера.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Настройки GPU воркера.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # PostgreSQL + POSTGRES_HOST: str = "postgres" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "antiplagiator" + POSTGRES_USER: str = "antiplagiator" + POSTGRES_PASSWORD: str = "changeme" + + # Redis + REDIS_URL: str = "redis://redis:6379/0" + + # RabbitMQ + RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/" + + # MinIO + MINIO_ENDPOINT: str = "minio:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "changeme" + MINIO_BUCKET_DOCS: str = "documents" + + # Elasticsearch + ELASTICSEARCH_URL: str = "http://elasticsearch:9200" + + # Ollama + OLLAMA_URL: str = "http://ollama:11434" + + # FAISS / ML + FAISS_INDEX_PATH: str = "/data/index/faiss.index" + FAISS_ID_MAP_PATH: str = "/data/index/faiss_id_map.json" + EMBED_MODEL: str = "paraphrase-multilingual-mpnet-base-v2" + EMBED_DEVICE: str = "cuda" + EMBED_BATCH_SIZE: int = 64 + EMBED_DIM: int = 768 # Размерность вектора paraphrase-multilingual-mpnet-base-v2 + FAISS_NLIST: int = 1024 # Количество кластеров для IVFFlat + FAISS_NPROBE: int = 64 # Количество кластеров для поиска + + # App + APP_URL: str = "https://academic.jze9.ru" + ENVIRONMENT: str = "development" + DEBUG: bool = False + + @property + def database_url_sync(self) -> str: + """Синхронный URL для SQLAlchemy (psycopg2).""" + return ( + f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + +settings = Settings() diff --git a/services/worker-gpu/app/db.py b/services/worker-gpu/app/db.py new file mode 100644 index 0000000..32e3444 --- /dev/null +++ b/services/worker-gpu/app/db.py @@ -0,0 +1,50 @@ +"""Синхронное подключение к PostgreSQL для Celery воркеров.""" + +import logging +from contextlib import contextmanager +from typing import Generator + +import redis as redis_lib +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import settings + +logger = logging.getLogger(__name__) + +# Синхронный движок для воркеров (psycopg2) +engine = create_engine( + settings.database_url_sync, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, + pool_recycle=3600, +) + +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +@contextmanager +def db_session() -> Generator[Session, None, None]: + """Контекстный менеджер для сессии БД.""" + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +# Redis клиент для кэширования результатов поиска +_redis_client: redis_lib.Redis | None = None + + +def get_redis() -> redis_lib.Redis: + """Получить или создать Redis клиент.""" + global _redis_client + if _redis_client is None: + _redis_client = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + return _redis_client diff --git a/services/worker-gpu/app/es_client.py b/services/worker-gpu/app/es_client.py new file mode 100644 index 0000000..cef6436 --- /dev/null +++ b/services/worker-gpu/app/es_client.py @@ -0,0 +1,151 @@ +"""Elasticsearch клиент для полнотекстового поиска (BM25).""" + +import logging +from typing import Any + +from elasticsearch import Elasticsearch, exceptions as es_exceptions + +from app.config import settings + +logger = logging.getLogger(__name__) + +_es_client: Elasticsearch | None = None + +INDEX_NAME = "documents" + + +def get_es_client() -> Elasticsearch: + """Получить или создать синглтон ES клиент.""" + global _es_client + if _es_client is None: + _es_client = Elasticsearch( + settings.ELASTICSEARCH_URL, + retry_on_timeout=True, + max_retries=3, + request_timeout=30, + ) + return _es_client + + +def search_fulltext( + query: str, + lang: str | None = None, + year_from: int | None = None, + year_to: int | None = None, + category: str | None = None, + size: int = 50, +) -> list[dict[str, Any]]: + """ + Полнотекстовый BM25 поиск в Elasticsearch. + + Args: + query: Поисковый запрос + lang: Фильтр языка (ru, en, None = все) + year_from: Фильтр года публикации (от) + year_to: Фильтр года публикации (до) + category: Фильтр тематической категории + size: Максимальное количество результатов + + Returns: + Список словарей с полями doc_id и score + """ + es = get_es_client() + + # Составить bool запрос + must_clauses: list[dict] = [ + { + "multi_match": { + "query": query, + "fields": ["title^3", "abstract^2", "authors"], + "type": "best_fields", + "operator": "or", + "minimum_should_match": "30%", + } + } + ] + + filter_clauses: list[dict] = [] + + if lang: + filter_clauses.append({"term": {"lang": lang}}) + + if year_from or year_to: + range_filter: dict = {"range": {"year": {}}} + if year_from: + range_filter["range"]["year"]["gte"] = year_from + if year_to: + range_filter["range"]["year"]["lte"] = year_to + filter_clauses.append(range_filter) + + body: dict[str, Any] = { + "query": { + "bool": { + "must": must_clauses, + "filter": filter_clauses, + } + }, + "size": size, + "_source": ["doc_id"], + } + + try: + response = es.search(index=INDEX_NAME, body=body) + hits = response["hits"]["hits"] + return [ + { + "doc_id": hit["_source"]["doc_id"], + "es_score": hit["_score"], + "es_id": hit["_id"], + } + for hit in hits + ] + except es_exceptions.ConnectionError as e: + logger.error(f"Elasticsearch недоступен: {e}") + return [] + except es_exceptions.NotFoundError: + logger.warning(f"Индекс {INDEX_NAME!r} не найден в Elasticsearch") + return [] + except Exception as e: + logger.error(f"Ошибка поиска в Elasticsearch: {e}") + return [] + + +def index_document(doc_id: int, doc_data: dict[str, Any]) -> bool: + """ + Индексировать документ в Elasticsearch. + + Args: + doc_id: ID документа в PostgreSQL + doc_data: Словарь с метаданными документа + + Returns: + True при успехе, False при ошибке + """ + es = get_es_client() + + doc = { + "doc_id": doc_id, + "source": doc_data.get("source"), + "title": doc_data.get("title", ""), + "abstract": doc_data.get("abstract", ""), + "authors": " ".join( + f"{a.get('last_name', '')} {a.get('first_name', '')}" + for a in doc_data.get("authors", []) + ), + "year": doc_data.get("year"), + "lang": doc_data.get("lang"), + "journal": doc_data.get("journal"), + "doi": doc_data.get("doi"), + "url": doc_data.get("url"), + } + + try: + es.index( + index=INDEX_NAME, + id=str(doc_id), + document=doc, + ) + return True + except Exception as e: + logger.error(f"Ошибка индексации документа {doc_id} в ES: {e}") + return False diff --git a/services/worker-gpu/app/faiss_manager.py b/services/worker-gpu/app/faiss_manager.py new file mode 100644 index 0000000..1129d3a --- /dev/null +++ b/services/worker-gpu/app/faiss_manager.py @@ -0,0 +1,210 @@ +"""Singleton менеджер FAISS GPU индекса. + +Использует IVFFlat (а не HNSW — не поддерживается на GPU). +Поддерживает graceful degradation на CPU если GPU недоступна. +""" + +import json +import logging +import os +from pathlib import Path +from typing import Optional + +import numpy as np + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class FAISSManager: + """Singleton для управления FAISS индексом на GPU.""" + + _index = None + _id_map: dict[int, int] = {} # faiss_internal_id -> doc_id (PostgreSQL) + _reverse_map: dict[int, int] = {} # doc_id -> faiss_internal_id + _use_gpu: bool = False + _is_trained: bool = False + + @classmethod + def load_or_create(cls) -> None: + """ + Загрузить индекс с диска или создать новый. + + Пытается перенести индекс на GPU, при ошибке остаётся на CPU. + """ + import faiss + + index_path = settings.FAISS_INDEX_PATH + id_map_path = settings.FAISS_ID_MAP_PATH + + # Загрузить ID маппинг + if os.path.exists(id_map_path): + with open(id_map_path, "r") as f: + raw_map = json.load(f) + cls._id_map = {int(k): int(v) for k, v in raw_map.items()} + cls._reverse_map = {v: k for k, v in cls._id_map.items()} + logger.info(f"ID маппинг загружен: {len(cls._id_map)} записей") + + if os.path.exists(index_path): + # Загрузить существующий индекс + logger.info(f"Загрузка FAISS индекса из {index_path}") + cpu_index = faiss.read_index(index_path) + cls._is_trained = cpu_index.is_trained + else: + # Создать новый IVFFlat индекс + logger.info("Создание нового FAISS IVFFlat индекса...") + quantizer = faiss.IndexFlatIP(settings.EMBED_DIM) + cpu_index = faiss.IndexIVFFlat( + quantizer, + settings.EMBED_DIM, + settings.FAISS_NLIST, + faiss.METRIC_INNER_PRODUCT, + ) + # IVFFlat требует обучения перед использованием + cls._is_trained = False + + # Попытка перенести на GPU + try: + res = faiss.StandardGpuResources() + cls._index = faiss.index_cpu_to_gpu(res, 0, cpu_index) + cls._use_gpu = True + logger.info("FAISS индекс размещён на GPU") + except Exception as e: + logger.warning(f"GPU недоступна: {e}. Используем CPU FAISS.") + cls._index = cpu_index + cls._use_gpu = False + + if cls._is_trained: + cls._index.nprobe = settings.FAISS_NPROBE + + @classmethod + def search(cls, query_vector: np.ndarray, k: int = 20) -> list[tuple[int, float]]: + """ + Поиск k ближайших векторов. + + Args: + query_vector: Нормализованный вектор запроса, форма (768,) + k: Количество результатов + + Returns: + Список кортежей (doc_id, cosine_score), отсортированных по убыванию score + """ + if cls._index is None or not cls._is_trained: + logger.warning("FAISS индекс не инициализирован или не обучен, пропускаем поиск") + return [] + + try: + query = query_vector.reshape(1, -1).astype(np.float32) + distances, indices = cls._index.search(query, k) + + results = [] + for idx, dist in zip(indices[0], distances[0]): + if idx == -1: + continue + doc_id = cls._id_map.get(int(idx)) + if doc_id is not None: + results.append((doc_id, float(dist))) + + return results + + except Exception as e: + logger.error(f"Ошибка поиска FAISS: {e}") + return [] + + @classmethod + def add_vectors(cls, vectors: np.ndarray, doc_ids: list[int]) -> None: + """ + Добавить векторы в индекс. + + Если индекс не обучен и накопилось достаточно векторов — обучить его. + + Args: + vectors: numpy массив формы (N, 768) + doc_ids: Список doc_id из PostgreSQL + """ + import faiss + + if cls._index is None: + cls.load_or_create() + + vectors = vectors.astype(np.float32) + + if not cls._is_trained: + # Для IVFFlat нужно минимум nlist * 39 обучающих примеров + min_train = settings.FAISS_NLIST * 39 + current_n = cls._index.ntotal if cls._index is not None else 0 + + if current_n + len(vectors) >= min_train: + logger.info(f"Обучение IVFFlat индекса на {current_n + len(vectors)} векторах...") + cls._index.train(vectors) + cls._is_trained = True + cls._index.nprobe = settings.FAISS_NPROBE + logger.info("Обучение завершено") + else: + logger.info( + f"Недостаточно векторов для обучения IVFFlat " + f"({current_n + len(vectors)} < {min_train}). " + "Используйте FlatIP до накопления достаточного количества документов." + ) + # Временный flat индекс для малого количества документов + if not hasattr(cls, '_flat_index') or cls._flat_index is None: + cls._flat_index = faiss.IndexFlatIP(settings.EMBED_DIM) + + # Добавить в flat индекс + start_id = cls._flat_index.ntotal + cls._flat_index.add(vectors) + for i, doc_id in enumerate(doc_ids): + internal_id = start_id + i + cls._id_map[internal_id] = doc_id + cls._reverse_map[doc_id] = internal_id + cls._save_id_map() + return + + if cls._is_trained: + start_id = cls._index.ntotal + cls._index.add(vectors) + for i, doc_id in enumerate(doc_ids): + internal_id = start_id + i + cls._id_map[internal_id] = doc_id + cls._reverse_map[doc_id] = internal_id + + cls._save_id_map() + logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}") + + @classmethod + def save(cls) -> None: + """Сохранить индекс на диск (CPU версия).""" + if cls._index is None: + return + + import faiss + + index_path = Path(settings.FAISS_INDEX_PATH) + index_path.parent.mkdir(parents=True, exist_ok=True) + + # Перенести на CPU перед сохранением + if cls._use_gpu: + cpu_index = faiss.index_gpu_to_cpu(cls._index) + else: + cpu_index = cls._index + + faiss.write_index(cpu_index, str(index_path)) + cls._save_id_map() + logger.info(f"FAISS индекс сохранён: {index_path} ({cpu_index.ntotal} векторов)") + + @classmethod + def _save_id_map(cls) -> None: + """Сохранить маппинг faiss_id -> doc_id на диск.""" + id_map_path = Path(settings.FAISS_ID_MAP_PATH) + id_map_path.parent.mkdir(parents=True, exist_ok=True) + with open(id_map_path, "w") as f: + json.dump({str(k): v for k, v in cls._id_map.items()}, f) + + @classmethod + @property + def total_vectors(cls) -> int: + """Количество векторов в индексе.""" + if cls._index is None: + return 0 + return cls._index.ntotal diff --git a/services/worker-gpu/app/model_manager.py b/services/worker-gpu/app/model_manager.py new file mode 100644 index 0000000..e26445a --- /dev/null +++ b/services/worker-gpu/app/model_manager.py @@ -0,0 +1,81 @@ +"""Singleton менеджер sentence-transformers модели. + +Модель загружается один раз при первом обращении и кэшируется в памяти GPU. +""" + +import logging + +import numpy as np + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class ModelManager: + """Singleton для управления эмбеддинг-моделью.""" + + _instance = None + _model = None + _device: str = settings.EMBED_DEVICE + + @classmethod + def get_model(cls): + """Получить или загрузить модель sentence-transformers.""" + if cls._model is None: + # Импорт здесь, чтобы не блокировать импорт модуля если torch не установлен + from sentence_transformers import SentenceTransformer + + logger.info( + f"Загрузка модели {settings.EMBED_MODEL!r} на устройство {settings.EMBED_DEVICE!r}..." + ) + try: + cls._model = SentenceTransformer( + settings.EMBED_MODEL, + device=settings.EMBED_DEVICE, + ) + logger.info( + f"Модель загружена. Размерность вектора: {cls._model.get_sentence_embedding_dimension()}" + ) + except Exception as e: + # Graceful degradation на CPU если CUDA недоступна + logger.warning(f"Не удалось загрузить модель на CUDA: {e}. Переключение на CPU.") + cls._device = "cpu" + cls._model = SentenceTransformer(settings.EMBED_MODEL, device="cpu") + + return cls._model + + @classmethod + def encode(cls, texts: list[str]) -> np.ndarray: + """ + Закодировать тексты в векторы. + + Args: + texts: Список текстов для кодирования + + Returns: + numpy массив формы (len(texts), 768), нормализованный для cosine similarity + """ + if not texts: + return np.array([]).reshape(0, settings.EMBED_DIM) + + model = cls.get_model() + vectors = model.encode( + texts, + batch_size=settings.EMBED_BATCH_SIZE, + normalize_embeddings=True, # Нормализация для cosine через inner product + show_progress_bar=len(texts) > 100, + convert_to_numpy=True, + ) + return vectors.astype(np.float32) + + @classmethod + def encode_single(cls, text: str) -> np.ndarray: + """Закодировать один текст. Удобный метод.""" + return cls.encode([text])[0] + + @classmethod + def unload(cls) -> None: + """Выгрузить модель из памяти (для тестов/диагностики).""" + cls._model = None + logger.info("Модель выгружена из памяти") diff --git a/services/worker-gpu/app/ollama_client.py b/services/worker-gpu/app/ollama_client.py new file mode 100644 index 0000000..6f853e9 --- /dev/null +++ b/services/worker-gpu/app/ollama_client.py @@ -0,0 +1,138 @@ +"""Клиент для Ollama HTTP API — LLM анализ парафраза и суммаризация.""" + +import json +import logging + +import httpx + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class OllamaClient: + """HTTP клиент для Ollama LLM.""" + + def __init__(self) -> None: + self.base_url = settings.OLLAMA_URL + self.model = "llama3:8b" + self.timeout = 60.0 # секунд + + def check_paraphrase(self, text_a: str, text_b: str) -> dict: + """ + Проверить является ли text_b парафразом text_a с помощью LLM. + + Args: + text_a: Исходный текст из базы данных (до 500 символов) + text_b: Проверяемый фрагмент (до 500 символов) + + Returns: + dict с полями: + - is_paraphrase: bool + - confidence: float 0.0-1.0 + - reason: str — краткое объяснение + """ + prompt = f"""Ты эксперт по академическому плагиату. Определи, является ли Текст B парафразом Текста A. + +Текст A (источник): {text_a[:500]} + +Текст B (проверяемый): {text_b[:500]} + +Критерии парафраза: передача тех же идей другими словами, перефразировка без ссылки на источник. + +Ответь ТОЛЬКО валидным JSON без пояснений и markdown: +{{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}""" + + try: + response = httpx.post( + f"{self.base_url}/api/generate", + json={ + "model": self.model, + "prompt": prompt, + "stream": False, + "format": "json", + "options": { + "temperature": 0.1, # Детерминированный вывод + "num_predict": 200, + }, + }, + timeout=self.timeout, + ) + response.raise_for_status() + + result = response.json() + llm_response = result.get("response", "{}") + + # Парсим JSON из ответа + parsed = json.loads(llm_response) + return { + "is_paraphrase": bool(parsed.get("is_paraphrase", False)), + "confidence": float(parsed.get("confidence", 0.0)), + "reason": str(parsed.get("reason", "")), + } + + except (httpx.TimeoutException, httpx.ConnectError) as e: + logger.warning(f"Ollama недоступна: {e}") + return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"} + except (json.JSONDecodeError, KeyError) as e: + logger.warning(f"Ошибка парсинга ответа Ollama: {e}") + return {"is_paraphrase": False, "confidence": 0.0, "reason": "Ошибка парсинга ответа"} + except Exception as e: + logger.error(f"Неожиданная ошибка при обращении к Ollama: {e}") + return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)} + + def summarize(self, title: str, abstract: str, lang: str = "ru") -> str: + """ + Сгенерировать краткое академическое изложение статьи. + + Args: + title: Название статьи + abstract: Аннотация статьи + lang: Язык ответа (ru/en) + + Returns: + Краткое изложение на указанном языке + """ + lang_instruction = "на русском языке" if lang == "ru" else "in English" + + prompt = f"""Сделай краткое академическое изложение {lang_instruction}. + +Название: {title} +Аннотация: {abstract[:1000]} + +Изложение должно: +- Содержать 3-5 предложений +- Передавать основную идею и результаты +- Быть написано академическим стилем +- НЕ копировать текст дословно + +Ответ:""" + + try: + response = httpx.post( + f"{self.base_url}/api/generate", + json={ + "model": self.model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.3, + "num_predict": 300, + }, + }, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json().get("response", "").strip() + + except Exception as e: + logger.error(f"Ошибка суммаризации через Ollama: {e}") + return abstract[:500] if abstract else title + + def is_available(self) -> bool: + """Проверить доступность Ollama сервера.""" + try: + response = httpx.get(f"{self.base_url}/api/tags", timeout=5.0) + return response.status_code == 200 + except Exception: + return False diff --git a/services/worker-gpu/app/tasks/__init__.py b/services/worker-gpu/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-gpu/app/tasks/plagiarism.py b/services/worker-gpu/app/tasks/plagiarism.py new file mode 100644 index 0000000..4044ab7 --- /dev/null +++ b/services/worker-gpu/app/tasks/plagiarism.py @@ -0,0 +1,262 @@ +"""Celery задачи проверки плагиата (уровни 3 и 4) и построения эмбеддингов.""" + +import logging +from typing import Any + +from celery.utils.log import get_task_logger +from sqlalchemy import select + +from app.celery_app import celery_app +from app.db import db_session + +logger = get_task_logger(__name__) + +# Пороговые значения +FAISS_SIMILARITY_THRESHOLD = 0.75 # Минимальный cosine score для подозрительного совпадения +LLM_CONFIDENCE_THRESHOLD = 0.7 # Минимальная уверенность LLM + + +def _get_doc_text(doc_id: int) -> str | None: + """Получить текст документа из базы данных.""" + from app.models import Document + with db_session() as session: + doc = session.get(Document, doc_id) + if doc is None: + return None + return doc.abstract or "" + + +def _get_doc_meta(doc_id: int) -> dict | None: + """Получить метаданные документа из базы данных.""" + from app.models import Document + with db_session() as session: + doc = session.get(Document, doc_id) + if doc is None: + return None + return { + "id": doc.id, + "title": doc.title, + "url": doc.url, + "source": doc.source, + "authors": doc.authors, + "year": doc.year, + } + + +@celery_app.task( + name="gpu.check_plagiarism", + bind=True, + max_retries=2, + default_retry_delay=120, +) +def check_plagiarism( + self, + task_id: str, + text: str, + fragments: list[dict], + level1_matches: list[dict] | None = None, + level2_matches: list[dict] | None = None, +) -> dict[str, Any]: + """ + Проверка плагиата уровни 3 (FAISS семантика) и 4 (Ollama LLM). + + Принимает результаты уровней 1 и 2 от worker-indexer и дополняет их. + + Args: + task_id: ID задачи в PostgreSQL + text: Полный текст документа + fragments: Список фрагментов для проверки: + [{"text": str, "start": int, "end": int}, ...] + level1_matches: Совпадения уровня 1 (Winnowing) + level2_matches: Совпадения уровня 2 (MinHash) + + Returns: + dict с результатами проверки + """ + from app.models import Task + + logger.info( + f"Проверка плагиата (ур. 3-4) для задачи {task_id!r}. " + f"Фрагментов: {len(fragments)}" + ) + + level1_matches = level1_matches or [] + level2_matches = level2_matches or [] + + # Обновить статус + with db_session() as session: + task = session.get(Task, task_id) + if task is None: + logger.error(f"Задача {task_id!r} не найдена") + return {} + task.status = "processing" + session.commit() + + try: + from app.faiss_manager import FAISSManager + from app.model_manager import ModelManager + from app.ollama_client import OllamaClient + + ollama = OllamaClient() + semantic_matches: list[dict] = [] + + for i, fragment in enumerate(fragments): + frag_text = fragment.get("text", "") + if len(frag_text.split()) < 10: + # Пропустить слишком короткие фрагменты + continue + + # Уровень 3: Семантический поиск через FAISS + frag_vec = ModelManager.encode_single(frag_text) + faiss_results = FAISSManager.search(frag_vec, k=10) + + for doc_id, score in faiss_results: + if score < FAISS_SIMILARITY_THRESHOLD: + continue + + # Уровень 4: LLM анализ парафраза для подозрительных совпадений + source_text = _get_doc_text(doc_id) or "" + doc_meta = _get_doc_meta(doc_id) + + if not doc_meta: + continue + + llm_result = {"is_paraphrase": False, "confidence": 0.0, "reason": ""} + if source_text: + llm_result = ollama.check_paraphrase(source_text, frag_text) + + if llm_result.get("is_paraphrase") and llm_result.get("confidence", 0.0) >= LLM_CONFIDENCE_THRESHOLD: + semantic_matches.append({ + "fragment": frag_text[:300], + "position_start": fragment.get("start", 0), + "position_end": fragment.get("end", len(frag_text)), + "similarity": round(score * 100, 1), + "method": "semantic+llm", + "confidence": llm_result["confidence"], + "reason": llm_result.get("reason", ""), + "source_title": doc_meta["title"], + "source_url": doc_meta["url"], + "source_db": doc_meta["source"], + }) + + if (i + 1) % 10 == 0: + logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}") + + # Объединить все совпадения и дедуплицировать по source_title + all_matches = level1_matches + level2_matches + semantic_matches + seen_sources: set[str] = set() + unique_matches = [] + for m in all_matches: + key = f"{m.get('source_title', '')}:{m.get('position_start', '')}" + if key not in seen_sources: + seen_sources.add(key) + unique_matches.append(m) + + # Вычислить общий процент схожести + total_frags = len(fragments) + flagged_frags = len(unique_matches) + overall_similarity = (flagged_frags / total_frags * 100) if total_frags > 0 else 0.0 + + result = { + "overall_similarity": round(overall_similarity, 2), + "matches": unique_matches, + "total_fragments": total_frags, + "flagged_fragments": flagged_frags, + "by_method": { + "exact": len(level1_matches), + "fuzzy": len(level2_matches), + "semantic_llm": len(semantic_matches), + }, + } + + # Сохранить результат + with db_session() as session: + task = session.get(Task, task_id) + if task: + task.result = result + task.status = "done" + task.queue_position = None + session.commit() + + logger.info( + f"Проверка плагиата завершена для задачи {task_id!r}. " + f"Схожесть: {overall_similarity:.1f}%, совпадений: {flagged_frags}" + ) + + # Уведомить пользователя + celery_app.send_task( + "notify.send_task_done", + args=[task_id], + queue="queue.notify", + ) + + return result + + except Exception as exc: + logger.error(f"Ошибка проверки плагиата для задачи {task_id!r}: {exc}", exc_info=True) + + try: + with db_session() as session: + task = session.get(Task, task_id) + if task: + task.status = "failed" + task.error = str(exc) + session.commit() + except Exception as db_exc: + logger.error(f"Не удалось обновить статус задачи: {db_exc}") + + raise self.retry(exc=exc, countdown=120) + + +@celery_app.task(name="gpu.embed_documents") +def embed_documents(doc_ids: list[int]) -> dict[str, Any]: + """ + Построить эмбеддинги для документов и добавить их в FAISS индекс. + + Вызывается воркером-индексером после добавления новых документов. + + Args: + doc_ids: Список ID документов в PostgreSQL + """ + if not doc_ids: + return {"status": "ok", "embedded": 0} + + from app.models import Document + from app.faiss_manager import FAISSManager + from app.model_manager import ModelManager + from sqlalchemy import select + + logger.info(f"Построение эмбеддингов для {len(doc_ids)} документов...") + + with db_session() as session: + docs = session.execute( + select(Document).where(Document.id.in_(doc_ids)) + ).scalars().all() + + if not docs: + logger.warning(f"Документы не найдены: {doc_ids}") + return {"status": "not_found", "embedded": 0} + + # Формируем тексты: title + abstract + texts = [ + f"{d.title}. {d.abstract or ''}".strip() + for d in docs + ] + ids = [d.id for d in docs] + + import numpy as np + vectors = ModelManager.encode(texts) + + FAISSManager.add_vectors(vectors, ids) + FAISSManager.save() + + # Обновить faiss_id в PostgreSQL + with db_session() as session: + for doc_id in ids: + doc = session.get(Document, doc_id) + if doc and doc_id in FAISSManager._reverse_map: + doc.faiss_id = FAISSManager._reverse_map[doc_id] + session.commit() + + logger.info(f"Встроено и проиндексировано {len(ids)} документов") + return {"status": "ok", "embedded": len(ids)} diff --git a/services/worker-gpu/app/tasks/search.py b/services/worker-gpu/app/tasks/search.py new file mode 100644 index 0000000..fa38cd9 --- /dev/null +++ b/services/worker-gpu/app/tasks/search.py @@ -0,0 +1,281 @@ +"""Celery задача семантического поиска источников.""" + +import hashlib +import json +import logging +from typing import Any + +from celery.utils.log import get_task_logger +from sqlalchemy import select + +from app.celery_app import celery_app +from app.db import db_session, get_redis + +logger = get_task_logger(__name__) + +# Время жизни кэша поиска (1 час) +SEARCH_CACHE_TTL = 3600 + +# Минимальный cosine score для включения в результаты +FAISS_MIN_SCORE = 0.3 + +# Вес BM25 и семантического скора при ранжировании +WEIGHT_SEMANTIC = 0.6 +WEIGHT_BM25 = 0.4 + + +def _get_cache_key(query: str, lang: str | None, year_from: int | None, year_to: int | None) -> str: + """Получить ключ кэша для поискового запроса.""" + key_str = f"{query}:{lang}:{year_from}:{year_to}" + return f"search:{hashlib.md5(key_str.encode()).hexdigest()}" + + +def _merge_and_rank( + faiss_results: list[tuple[int, float]], + es_results: list[dict], +) -> list[dict[str, Any]]: + """ + Объединить и ранжировать результаты FAISS и Elasticsearch. + + Нормализует оба скора в диапазон [0, 1] и взвешенно суммирует. + + Args: + faiss_results: [(doc_id, cosine_score), ...] из FAISS + es_results: [{"doc_id": int, "es_score": float}, ...] из ES + + Returns: + Список словарей с doc_id и combined_score, отсортированный по убыванию + """ + scores: dict[int, dict[str, float]] = {} + + # Нормализация FAISS скоров + if faiss_results: + max_faiss = max(s for _, s in faiss_results) or 1.0 + for doc_id, score in faiss_results: + scores[doc_id] = {"faiss": score / max_faiss, "es": 0.0} + + # Нормализация BM25 скоров + if es_results: + max_es = max(r["es_score"] for r in es_results) or 1.0 + for r in es_results: + doc_id = r["doc_id"] + norm_score = r["es_score"] / max_es + if doc_id in scores: + scores[doc_id]["es"] = norm_score + else: + scores[doc_id] = {"faiss": 0.0, "es": norm_score} + + # Комбинированный скор + ranked = [] + for doc_id, s in scores.items(): + combined = WEIGHT_SEMANTIC * s["faiss"] + WEIGHT_BM25 * s["es"] + ranked.append({ + "doc_id": doc_id, + "combined_score": round(combined, 4), + "faiss_score": round(s["faiss"], 4), + "es_score": round(s["es"], 4), + }) + + ranked.sort(key=lambda x: x["combined_score"], reverse=True) + return ranked + + +def _enrich_from_db(ranked: list[dict], session) -> list[dict[str, Any]]: + """ + Обогатить результаты метаданными из PostgreSQL и добавить ГОСТ-цитату. + + Args: + ranked: Список с doc_id и скорами + session: SQLAlchemy сессия + + Returns: + Список источников с полными метаданными + """ + from app.models import Document + + if not ranked: + return [] + + doc_ids = [r["doc_id"] for r in ranked] + docs = session.execute( + select(Document).where(Document.id.in_(doc_ids)) + ).scalars().all() + + doc_map = {d.id: d for d in docs} + + results = [] + for r in ranked: + doc = doc_map.get(r["doc_id"]) + if not doc: + continue + + # Простая ГОСТ-цитата (полная форматирует worker-gost) + gost_citation = _format_gost_simple(doc) + + results.append({ + "id": doc.id, + "title": doc.title, + "authors": doc.authors, + "year": doc.year, + "journal": doc.journal, + "abstract": doc.abstract[:300] if doc.abstract else None, + "url": doc.url, + "doi": doc.doi, + "relevance_score": r["combined_score"], + "gost_citation": gost_citation, + "source_db": doc.source, + }) + + return results + + +def _format_gost_simple(doc) -> str: + """Простое ГОСТ-форматирование (без полной логики worker-gost).""" + authors = doc.authors or [] + author_str = "" + if authors: + first = authors[0] + last_name = first.get("last_name", "") + initials = first.get("initials", "") + if len(authors) == 1: + author_str = f"{last_name} {initials}" + elif len(authors) <= 3: + parts = [f"{a.get('last_name', '')} {a.get('initials', '')}" for a in authors] + author_str = ", ".join(parts) + else: + author_str = f"{last_name} {initials} и др." + + title = doc.title or "" + year = str(doc.year) if doc.year else "б. г." + journal = doc.journal or "" + pages = doc.pages or "" + + if journal: + citation = f"{author_str}. {title} // {journal}. — {year}." + if pages: + citation += f" — С. {pages}." + else: + citation = f"{author_str}. {title}. — {year}." + + return citation.strip() + + +@celery_app.task( + name="gpu.search_semantic", + bind=True, + max_retries=3, + default_retry_delay=60, +) +def search_semantic( + self, + task_id: str, + query: str, + lang: str | None = None, + year_from: int | None = None, + year_to: int | None = None, +) -> dict[str, Any]: + """ + Семантический поиск источников. + + Алгоритм: + 1. Нормализация запроса + 2. Проверка Redis кэша + 3. Параллельно: FAISS GPU cosine + Elasticsearch BM25 + 4. Merge + дедупликация + ранжирование + 5. Обогащение метаданными из PostgreSQL + 6. Сохранение результата в task.result + 7. Диспатч notify.send_task_done + + Args: + task_id: ID задачи в PostgreSQL + query: Поисковый запрос + lang: Фильтр языка + year_from: Фильтр года (от) + year_to: Фильтр года (до) + """ + from app.models import Task + + logger.info(f"Начало поиска для задачи {task_id!r}, запрос: {query[:50]!r}") + + # Обновить статус на processing + with db_session() as session: + task = session.get(Task, task_id) + if task is None: + logger.error(f"Задача {task_id!r} не найдена в БД") + return {} + task.status = "processing" + session.commit() + + try: + redis = get_redis() + cache_key = _get_cache_key(query, lang, year_from, year_to) + + # Проверить кэш + cached = redis.get(cache_key) + if cached: + logger.info(f"Результат поиска получен из кэша: {cache_key}") + results = json.loads(cached) + else: + # Нормализация запроса + query_normalized = query.strip() + + # Закодировать запрос в вектор + from app.model_manager import ModelManager + query_vec = ModelManager.encode_single(query_normalized) + + # FAISS семантический поиск + from app.faiss_manager import FAISSManager + faiss_results = FAISSManager.search(query_vec, k=50) + logger.info(f"FAISS: найдено {len(faiss_results)} результатов") + + # Elasticsearch BM25 поиск + from app.es_client import search_fulltext + es_results = search_fulltext(query_normalized, lang=lang, year_from=year_from, year_to=year_to) + logger.info(f"ES BM25: найдено {len(es_results)} результатов") + + # Объединить и ранжировать + ranked = _merge_and_rank(faiss_results, es_results) + + # Обогатить метаданными + with db_session() as session: + results = _enrich_from_db(ranked[:30], session) # Топ-30 результатов + + # Кэшировать на 1 час + redis.setex(cache_key, SEARCH_CACHE_TTL, json.dumps(results, ensure_ascii=False, default=str)) + + # Сохранить результат в задачу + with db_session() as session: + task = session.get(Task, task_id) + if task: + task.result = {"sources": results, "total": len(results), "query": query} + task.status = "done" + task.queue_position = None + session.commit() + + logger.info(f"Задача {task_id!r} выполнена. Найдено {len(results)} источников.") + + # Уведомить пользователя + celery_app.send_task( + "notify.send_task_done", + args=[task_id], + queue="queue.notify", + ) + + return {"task_id": task_id, "total": len(results)} + + except Exception as exc: + logger.error(f"Ошибка при выполнении поиска для задачи {task_id!r}: {exc}", exc_info=True) + + # Обновить статус задачи на failed + try: + with db_session() as session: + task = session.get(Task, task_id) + if task: + task.status = "failed" + task.error = str(exc) + session.commit() + except Exception as db_exc: + logger.error(f"Не удалось обновить статус задачи: {db_exc}") + + # Повторить попытку + raise self.retry(exc=exc, countdown=60) diff --git a/services/worker-gpu/requirements.txt b/services/worker-gpu/requirements.txt new file mode 100644 index 0000000..7a4e7f8 --- /dev/null +++ b/services/worker-gpu/requirements.txt @@ -0,0 +1,13 @@ +celery==5.4.0 +redis==5.0.4 +sqlalchemy==2.0.30 +psycopg2-binary==2.9.9 +sentence-transformers==3.0.0 +faiss-gpu==1.7.4 +torch==2.3.0 +numpy==1.26.4 +httpx==0.27.0 +minio==7.2.7 +datasketch==1.6.5 +pydantic-settings==2.2.1 +elasticsearch==8.13.0 diff --git a/services/worker-indexer/Dockerfile b/services/worker-indexer/Dockerfile new file mode 100644 index 0000000..fa67137 --- /dev/null +++ b/services/worker-indexer/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +# Системные зависимости для PDF обработки +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + g++ \ + libpq-dev \ + libmupdf-dev \ + mupdf-tools \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["celery", "-A", "app.celery_app", "worker", "-Q", "queue.index", "-c", "4", "-n", "indexer@%h", "--loglevel=info"] diff --git a/services/worker-indexer/app/__init__.py b/services/worker-indexer/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-indexer/app/algorithms/__init__.py b/services/worker-indexer/app/algorithms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-indexer/app/algorithms/minhash.py b/services/worker-indexer/app/algorithms/minhash.py new file mode 100644 index 0000000..57fd22b --- /dev/null +++ b/services/worker-indexer/app/algorithms/minhash.py @@ -0,0 +1,118 @@ +"""MinHash LSH для нечёткого поиска похожих документов. + +Позволяет быстро находить документы с похожим содержимым +без точного сравнения всех пар. +""" + +import logging + +from datasketch import MinHash, MinHashLSH + +logger = logging.getLogger(__name__) + +# Параметры MinHash LSH +LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты +LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память) + +# Глобальный LSH индекс (in-memory) +_lsh: MinHashLSH | None = None + + +def get_lsh() -> MinHashLSH: + """Получить или создать глобальный LSH индекс.""" + global _lsh + if _lsh is None: + _lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM) + logger.info("MinHash LSH индекс создан") + return _lsh + + +def get_shingles(text: str, k: int = 3) -> set[str]: + """ + Получить k-слоговые шинглы из текста. + + Args: + text: Исходный текст + k: Размер шингла (количество слов) + + Returns: + Множество шинглов + """ + words = text.lower().split() + if len(words) < k: + return {" ".join(words)} if words else set() + return {" ".join(words[i : i + k]) for i in range(len(words) - k + 1)} + + +def text_to_minhash(text: str) -> MinHash: + """ + Создать MinHash подпись для текста. + + Args: + text: Исходный текст + + Returns: + MinHash объект + """ + m = MinHash(num_perm=LSH_NUM_PERM) + for shingle in get_shingles(text): + m.update(shingle.encode("utf-8")) + return m + + +def add_to_lsh(doc_key: str, text: str) -> None: + """ + Добавить документ в LSH индекс. + + Args: + doc_key: Уникальный ключ документа (например, "doc:{id}") + text: Текст документа + """ + lsh = get_lsh() + m = text_to_minhash(text) + try: + lsh.insert(doc_key, m) + except ValueError: + # Документ уже в индексе — игнорируем + pass + + +def find_similar(text: str) -> list[str]: + """ + Найти похожие документы в LSH индексе. + + Args: + text: Текст для поиска похожих + + Returns: + Список ключей похожих документов + """ + lsh = get_lsh() + m = text_to_minhash(text) + try: + return lsh.query(m) + except Exception as e: + logger.warning(f"Ошибка запроса к MinHash LSH: {e}") + return [] + + +def compute_jaccard_minhash(text_a: str, text_b: str) -> float: + """ + Оценить схожесть двух текстов через MinHash Jaccard. + + Args: + text_a: Первый текст + text_b: Второй текст + + Returns: + Оценка схожести Жаккара через MinHash + """ + m_a = text_to_minhash(text_a) + m_b = text_to_minhash(text_b) + return m_a.jaccard(m_b) + + +def reset_lsh() -> None: + """Сбросить LSH индекс (для тестов).""" + global _lsh + _lsh = None diff --git a/services/worker-indexer/app/algorithms/winnowing.py b/services/worker-indexer/app/algorithms/winnowing.py new file mode 100644 index 0000000..6130171 --- /dev/null +++ b/services/worker-indexer/app/algorithms/winnowing.py @@ -0,0 +1,120 @@ +"""Алгоритм Winnowing для fingerprinting текстов. + +Winnowing — алгоритм выбора минимального хэша в скользящем окне. +Используется для нахождения точных и частичных совпадений текстов. +""" + +import xxhash + + +def get_ngrams(tokens: list[str], k: int = 5) -> list[str]: + """ + Сформировать n-граммы из токенов. + + Args: + tokens: Список слов + k: Размер n-граммы + + Returns: + Список n-грамм в виде строк + """ + if len(tokens) < k: + return [] + return [" ".join(tokens[i : i + k]) for i in range(len(tokens) - k + 1)] + + +def hash_ngram(ngram: str) -> int: + """ + Хэшировать n-грамму через xxHash (быстро, детерминированно). + + Args: + ngram: n-грамма для хэширования + + Returns: + 64-битный хэш + """ + return xxhash.xxh64(ngram.encode("utf-8")).intdigest() + + +def winnow(text: str, k: int = 5, window: int = 4) -> set[int]: + """ + Алгоритм Winnowing для построения fingerprint документа. + + Шаги: + 1. Токенизация (lowercase) + 2. Построение k-грамм + 3. Хэширование k-грамм + 4. Скользящее окно — выбор минимального хэша в каждой позиции + + Args: + text: Исходный текст + k: Размер k-граммы (n-gram) + window: Размер скользящего окна + + Returns: + Множество отобранных хэшей (fingerprint) + """ + tokens = text.lower().split() + + if len(tokens) < k: + return set() + + ngrams = get_ngrams(tokens, k) + hashes = [hash_ngram(ng) for ng in ngrams] + + if not hashes: + return set() + + # Скользящее окно — выбираем минимальный хэш в каждом окне + fingerprint: set[int] = set() + prev_min_idx = -1 + + for i in range(len(hashes) - window + 1): + window_hashes = hashes[i : i + window] + min_val = min(window_hashes) + min_idx = i + window_hashes.index(min_val) + + # Добавляем только если это новый минимум или позиция изменилась + if min_idx != prev_min_idx: + fingerprint.add(min_val) + prev_min_idx = min_idx + + return fingerprint + + +def jaccard_similarity(fp_a: set[int], fp_b: set[int]) -> float: + """ + Коэффициент Жаккара для двух fingerprint'ов. + + Args: + fp_a: Fingerprint документа A + fp_b: Fingerprint документа B + + Returns: + Коэффициент сходства от 0.0 до 1.0 + """ + if not fp_a or not fp_b: + return 0.0 + + intersection = len(fp_a & fp_b) + union = len(fp_a | fp_b) + + return intersection / union if union > 0 else 0.0 + + +def compute_similarity(text_a: str, text_b: str, k: int = 5, window: int = 4) -> float: + """ + Вычислить схожесть двух текстов через Winnowing. + + Args: + text_a: Первый текст + text_b: Второй текст + k: Размер k-граммы + window: Размер окна Winnowing + + Returns: + Схожесть от 0.0 до 1.0 + """ + fp_a = winnow(text_a, k=k, window=window) + fp_b = winnow(text_b, k=k, window=window) + return jaccard_similarity(fp_a, fp_b) diff --git a/services/worker-indexer/app/celery_app.py b/services/worker-indexer/app/celery_app.py new file mode 100644 index 0000000..a0512ea --- /dev/null +++ b/services/worker-indexer/app/celery_app.py @@ -0,0 +1,30 @@ +"""Celery приложение индексер-воркера.""" + +from celery import Celery + +from app.config import settings + +celery_app = Celery( + "worker_indexer", + broker=settings.RABBITMQ_URL, + backend=settings.REDIS_URL, + include=["app.tasks.index"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Europe/Moscow", + enable_utc=True, + task_track_started=True, + task_routes={ + "gpu.*": {"queue": "queue.gpu"}, + "index.*": {"queue": "queue.index"}, + "notify.*": {"queue": "queue.notify"}, + "gost.*": {"queue": "queue.gost"}, + }, + task_acks_late=True, + task_reject_on_worker_lost=True, + result_expires=86400, +) diff --git a/services/worker-indexer/app/config.py b/services/worker-indexer/app/config.py new file mode 100644 index 0000000..c80a8d5 --- /dev/null +++ b/services/worker-indexer/app/config.py @@ -0,0 +1,55 @@ +"""Конфигурация индексер-воркера.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Настройки индексер-воркера.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # PostgreSQL + POSTGRES_HOST: str = "postgres" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "antiplagiator" + POSTGRES_USER: str = "antiplagiator" + POSTGRES_PASSWORD: str = "changeme" + + # Redis + REDIS_URL: str = "redis://redis:6379/0" + + # RabbitMQ + RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/" + + # MinIO + MINIO_ENDPOINT: str = "minio:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "changeme" + MINIO_BUCKET_DOCS: str = "documents" + + # Elasticsearch + ELASTICSEARCH_URL: str = "http://elasticsearch:9200" + + # Настройки обработки текста + FRAGMENT_WINDOW_WORDS: int = 200 # Размер окна для фрагментов + FRAGMENT_OVERLAP_WORDS: int = 50 # Перекрытие фрагментов + MAX_FINGERPRINTS_PER_DOC: int = 500 # Максимум хэшей Winnowing на документ + + # App + ENVIRONMENT: str = "development" + DEBUG: bool = False + + @property + def database_url_sync(self) -> str: + """Синхронный URL для SQLAlchemy.""" + return ( + f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + +settings = Settings() diff --git a/services/worker-indexer/app/db.py b/services/worker-indexer/app/db.py new file mode 100644 index 0000000..5eee69a --- /dev/null +++ b/services/worker-indexer/app/db.py @@ -0,0 +1,67 @@ +"""Синхронное подключение к PostgreSQL и MinIO для индексер-воркера.""" + +import logging +from contextlib import contextmanager +from typing import Generator + +from minio import Minio +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import settings + +logger = logging.getLogger(__name__) + +# Синхронный движок SQLAlchemy +engine = create_engine( + settings.database_url_sync, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, + pool_recycle=3600, +) + +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +@contextmanager +def db_session() -> Generator[Session, None, None]: + """Контекстный менеджер для сессии БД.""" + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +_minio_client: Minio | None = None + + +def get_minio() -> Minio: + """Получить или создать MinIO клиент.""" + global _minio_client + if _minio_client is None: + _minio_client = Minio( + settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=False, + ) + return _minio_client + + +def update_task_status(task_id: str, status: str, error: str | None = None) -> None: + """Обновить статус задачи в БД.""" + from app.models import Task + + with db_session() as session: + task = session.get(Task, task_id) + if task: + task.status = status + if error: + task.error = error + session.commit() diff --git a/services/worker-indexer/app/extractors/__init__.py b/services/worker-indexer/app/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-indexer/app/extractors/docx.py b/services/worker-indexer/app/extractors/docx.py new file mode 100644 index 0000000..219d8bd --- /dev/null +++ b/services/worker-indexer/app/extractors/docx.py @@ -0,0 +1,69 @@ +"""Извлечение текста из DOCX файлов через python-docx.""" + +import io +import logging + +logger = logging.getLogger(__name__) + + +def extract_text_from_docx(data: bytes) -> str: + """ + Извлечь текст из DOCX файла. + + Обрабатывает параграфы, таблицы и заголовки. + + Args: + data: Байты DOCX файла + + Returns: + Извлечённый текст + + Raises: + ValueError: Если не удалось открыть DOCX + """ + from docx import Document as DocxDocument + from docx.oxml.ns import qn + + try: + doc = DocxDocument(io.BytesIO(data)) + except Exception as e: + raise ValueError(f"Не удалось открыть DOCX: {e}") from e + + texts: list[str] = [] + + # Основной текст из параграфов + for para in doc.paragraphs: + text = para.text.strip() + if text: + texts.append(text) + + # Текст из таблиц + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + cell_text = cell.text.strip() + if cell_text: + texts.append(cell_text) + + return "\n".join(texts) + + +def extract_text_from_txt(data: bytes) -> str: + """ + Извлечь текст из TXT файла с определением кодировки. + + Args: + data: Байты TXT файла + + Returns: + Текст файла + """ + # Пробуем UTF-8, затем cp1251 (Windows-1251 для русских текстов) + for encoding in ("utf-8", "cp1251", "latin-1"): + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + + # Последний вариант — игнорировать ошибки + return data.decode("utf-8", errors="ignore") diff --git a/services/worker-indexer/app/extractors/pdf.py b/services/worker-indexer/app/extractors/pdf.py new file mode 100644 index 0000000..2dbbbe3 --- /dev/null +++ b/services/worker-indexer/app/extractors/pdf.py @@ -0,0 +1,80 @@ +"""Извлечение текста из PDF файлов через PyMuPDF.""" + +import logging + +logger = logging.getLogger(__name__) + + +def extract_text_from_pdf(data: bytes) -> str: + """ + Извлечь текст из PDF файла. + + Обрабатывает многостраничные документы. + Удаляет лишние переносы строк и пробелы. + + Args: + data: Байты PDF файла + + Returns: + Извлечённый текст + + Raises: + ValueError: Если не удалось открыть PDF + """ + import fitz # PyMuPDF + + try: + doc = fitz.open(stream=data, filetype="pdf") + except Exception as e: + raise ValueError(f"Не удалось открыть PDF: {e}") from e + + texts: list[str] = [] + + for page_num, page in enumerate(doc): + try: + page_text = page.get_text("text") + if page_text.strip(): + texts.append(page_text) + except Exception as e: + logger.warning(f"Ошибка извлечения текста со страницы {page_num}: {e}") + continue + + doc.close() + + full_text = "\n".join(texts) + + # Нормализация: убрать множественные переносы строк + import re + full_text = re.sub(r"\n{3,}", "\n\n", full_text) + full_text = re.sub(r"[ \t]+", " ", full_text) + + return full_text.strip() + + +def extract_metadata_from_pdf(data: bytes) -> dict: + """ + Извлечь метаданные из PDF (title, author, subject и т.д.). + + Args: + data: Байты PDF файла + + Returns: + Словарь метаданных + """ + import fitz + + try: + doc = fitz.open(stream=data, filetype="pdf") + metadata = doc.metadata or {} + doc.close() + return { + "title": metadata.get("title", ""), + "author": metadata.get("author", ""), + "subject": metadata.get("subject", ""), + "keywords": metadata.get("keywords", ""), + "creator": metadata.get("creator", ""), + "pages": doc.page_count if hasattr(doc, "page_count") else 0, + } + except Exception as e: + logger.warning(f"Ошибка извлечения метаданных PDF: {e}") + return {} diff --git a/services/worker-indexer/app/tasks/__init__.py b/services/worker-indexer/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py new file mode 100644 index 0000000..ce4d8ed --- /dev/null +++ b/services/worker-indexer/app/tasks/index.py @@ -0,0 +1,329 @@ +"""Celery задачи индексации документов и проверки плагиата (уровни 1-2).""" + +import io +import logging +from pathlib import Path +from typing import Any + +from celery.utils.log import get_task_logger +from sqlalchemy import func, select + +from app.algorithms.minhash import add_to_lsh, find_similar +from app.algorithms.winnowing import winnow +from app.celery_app import celery_app +from app.config import settings +from app.db import db_session, get_minio, update_task_status +from app.extractors.docx import extract_text_from_docx, extract_text_from_txt +from app.extractors.pdf import extract_text_from_pdf + +logger = get_task_logger(__name__) + + +def _split_into_fragments( + text: str, + window: int = 200, + overlap: int = 50, +) -> list[dict[str, Any]]: + """ + Разбить текст на фрагменты для проверки плагиата. + + Использует скользящее окно с перекрытием. + + Args: + text: Исходный текст + window: Размер окна в словах + overlap: Перекрытие между фрагментами в словах + + Returns: + Список словарей {"text": str, "start": int, "end": int} + """ + words = text.split() + if not words: + return [] + + fragments = [] + step = window - overlap + char_positions = [] + + # Вычислить позиции символов для каждого слова + pos = 0 + for word in words: + char_positions.append(pos) + pos += len(word) + 1 # +1 для пробела + + for i in range(0, max(1, len(words) - window + 1), step): + chunk_words = words[i : i + window] + if len(chunk_words) < 20: # Пропустить слишком короткие фрагменты + continue + + start_char = char_positions[i] + end_idx = min(i + window - 1, len(words) - 1) + end_char = char_positions[end_idx] + len(words[end_idx]) + + fragments.append({ + "text": " ".join(chunk_words), + "start": start_char, + "end": end_char, + }) + + return fragments + + +@celery_app.task( + name="index.extract_and_check", + bind=True, + max_retries=3, + default_retry_delay=60, +) +def extract_and_check( + self, + task_id: str, + minio_key: str, + filename: str, +) -> dict[str, Any]: + """ + Извлечь текст из документа и проверить плагиат (уровни 1-2). + + Алгоритм: + 1. Скачать файл из MinIO + 2. Извлечь текст (PDF/DOCX/TXT) + 3. Разбить на фрагменты по 200 слов с перекрытием 50 слов + 4. Уровень 1: Winnowing против базы fingerprints в PostgreSQL + 5. Уровень 2: MinHash LSH нечёткий поиск + 6. Диспатч gpu.check_plagiarism для уровней 3 и 4 + + Args: + task_id: ID задачи в PostgreSQL + minio_key: Ключ объекта в MinIO + filename: Оригинальное имя файла + """ + logger.info(f"Извлечение текста для задачи {task_id!r}, файл: {filename!r}") + + update_task_status(task_id, "processing") + + try: + # Скачать из MinIO + minio = get_minio() + response = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key) + file_data = response.read() + response.close() + response.release_conn() + + logger.info(f"Файл скачан из MinIO: {minio_key} ({len(file_data)} байт)") + + # Извлечь текст в зависимости от формата + ext = Path(filename).suffix.lower() + if ext == ".pdf": + text = extract_text_from_pdf(file_data) + elif ext == ".docx": + text = extract_text_from_docx(file_data) + else: + text = extract_text_from_txt(file_data) + + if not text.strip(): + raise ValueError("Не удалось извлечь текст из документа") + + logger.info(f"Текст извлечён: {len(text)} символов, {len(text.split())} слов") + + # Разбить на фрагменты + fragments = _split_into_fragments( + text, + window=settings.FRAGMENT_WINDOW_WORDS, + overlap=settings.FRAGMENT_OVERLAP_WORDS, + ) + logger.info(f"Фрагментов создано: {len(fragments)}") + + # ──── Уровень 1: Winnowing fingerprints ──────────────────────────────── + level1_matches: list[dict] = [] + doc_fingerprint = winnow(text) + + if doc_fingerprint: + from app.models import Document, Fingerprint + + with db_session() as session: + hashes = list(doc_fingerprint)[: settings.MAX_FINGERPRINTS_PER_DOC] + + # Найти совпадения в базе fingerprints + matching_docs = session.execute( + select( + Fingerprint.doc_id, + func.count(Fingerprint.id).label("match_count"), + ) + .where(Fingerprint.hash_value.in_(hashes)) + .group_by(Fingerprint.doc_id) + .having(func.count(Fingerprint.id) > len(hashes) * 0.1) + .order_by(func.count(Fingerprint.id).desc()) + .limit(20) + ).all() + + for doc_id, match_count in matching_docs: + similarity = match_count / len(hashes) * 100 + if similarity < 20: + continue + + doc = session.get(Document, doc_id) + if not doc: + continue + + level1_matches.append({ + "fragment": text[:200], + "position_start": 0, + "position_end": len(text), + "similarity": round(similarity, 1), + "method": "exact", + "source_title": doc.title, + "source_url": doc.url, + "source_db": doc.source, + }) + + logger.info(f"Уровень 1 (Winnowing): {len(level1_matches)} совпадений") + + # ──── Уровень 2: MinHash LSH ──────────────────────────────────────────── + level2_matches: list[dict] = [] + similar_keys = find_similar(text) + + if similar_keys: + from app.models import Document + + with db_session() as session: + for key in similar_keys[:10]: + # Ключ формата "doc:{id}" + try: + doc_id = int(key.split(":")[-1]) + doc = session.get(Document, doc_id) + if not doc: + continue + + level2_matches.append({ + "fragment": text[:200], + "position_start": 0, + "position_end": len(text), + "similarity": 60.0, # MinHash даёт только факт похожести + "method": "fuzzy", + "source_title": doc.title, + "source_url": doc.url, + "source_db": doc.source, + }) + except (ValueError, IndexError): + continue + + logger.info(f"Уровень 2 (MinHash): {len(level2_matches)} совпадений") + + # ──── Диспатч GPU задачи (уровни 3-4) ───────────────────────────────── + # Передаём только текстовые данные (JSON-сериализуемые) + celery_app.send_task( + "gpu.check_plagiarism", + args=[task_id, text, fragments], + kwargs={ + "level1_matches": level1_matches, + "level2_matches": level2_matches, + }, + queue="queue.gpu", + ) + + logger.info(f"GPU задача отправлена для задачи {task_id!r}") + return { + "task_id": task_id, + "fragments": len(fragments), + "level1": len(level1_matches), + "level2": len(level2_matches), + } + + except Exception as exc: + logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True) + update_task_status(task_id, "failed", str(exc)) + raise self.retry(exc=exc, countdown=60) + + +@celery_app.task(name="index.add_document") +def add_document(doc_data: dict[str, Any]) -> dict[str, Any]: + """ + Добавить документ из внешнего источника в систему. + + Алгоритм: + 1. Дедупликация по ext_id + 2. Сохранить метаданные в PostgreSQL + 3. Индексировать в Elasticsearch + 4. Вычислить Winnowing fingerprints + 5. Добавить в MinHash LSH + 6. Диспатч gpu.embed_documents для FAISS эмбеддингов + + Args: + doc_data: Словарь с метаданными документа + + Returns: + dict со статусом операции и doc_id + """ + from app.models import Document, Fingerprint + + ext_id = doc_data.get("ext_id") + if not ext_id: + return {"status": "error", "reason": "ext_id обязателен"} + + with db_session() as session: + # Проверить дублирование + existing = session.execute( + select(Document).where(Document.ext_id == ext_id) + ).scalar_one_or_none() + + if existing: + return {"status": "duplicate", "doc_id": existing.id} + + # Создать документ + allowed_fields = {c.key for c in Document.__table__.columns} + doc_kwargs = {k: v for k, v in doc_data.items() if k in allowed_fields} + + doc = Document(**doc_kwargs) + session.add(doc) + session.flush() + doc_id = doc.id + + # Вычислить fingerprints + text = doc_data.get("full_text") or doc_data.get("abstract", "") or "" + if text: + fp = winnow(text) + fingerprints_to_add = list(fp)[: settings.MAX_FINGERPRINTS_PER_DOC] + for i, hash_val in enumerate(fingerprints_to_add): + session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i)) + + # Добавить в MinHash LSH (in-memory) + add_to_lsh(f"doc:{doc_id}", text) + + session.commit() + + logger.info(f"Документ {doc_id} добавлен в PostgreSQL: {doc_data.get('title', '')[:50]!r}") + + # Индексация в Elasticsearch + try: + from elasticsearch import Elasticsearch + from app.config import settings as cfg + + es = Elasticsearch(cfg.ELASTICSEARCH_URL) + es_doc = { + "doc_id": doc_id, + "source": doc_data.get("source"), + "title": doc_data.get("title", ""), + "abstract": doc_data.get("abstract", ""), + "authors": " ".join( + f"{a.get('last_name', '')} {a.get('first_name', '')}" + for a in doc_data.get("authors", []) + ), + "year": doc_data.get("year"), + "lang": doc_data.get("lang"), + "journal": doc_data.get("journal"), + "doi": doc_data.get("doi"), + "url": doc_data.get("url"), + } + es.index(index="documents", id=str(doc_id), document=es_doc) + logger.info(f"Документ {doc_id} проиндексирован в Elasticsearch") + except Exception as e: + logger.warning(f"Ошибка индексации в ES для документа {doc_id}: {e}") + + # Диспатч FAISS эмбеддингов + celery_app.send_task( + "gpu.embed_documents", + args=[[doc_id]], + queue="queue.gpu", + ) + + return {"status": "indexed", "doc_id": doc_id} diff --git a/services/worker-indexer/requirements.txt b/services/worker-indexer/requirements.txt new file mode 100644 index 0000000..4b46fab --- /dev/null +++ b/services/worker-indexer/requirements.txt @@ -0,0 +1,13 @@ +celery==5.4.0 +redis==5.0.4 +sqlalchemy==2.0.30 +psycopg2-binary==2.9.9 +elasticsearch==8.13.0 +minio==7.2.7 +PyMuPDF==1.24.0 +python-docx==1.1.0 +datasketch==1.6.5 +xxhash==3.4.1 +langdetect==1.0.9 +pydantic-settings==2.2.1 +httpx==0.27.0 diff --git a/services/worker-notifier/Dockerfile b/services/worker-notifier/Dockerfile new file mode 100644 index 0000000..32887a8 --- /dev/null +++ b/services/worker-notifier/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["celery", "-A", "app.celery_app", "worker", "-Q", "queue.notify", "-c", "16", "-n", "notifier@%h", "--loglevel=info"] diff --git a/services/worker-notifier/app/__init__.py b/services/worker-notifier/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-notifier/app/celery_app.py b/services/worker-notifier/app/celery_app.py new file mode 100644 index 0000000..9ddac09 --- /dev/null +++ b/services/worker-notifier/app/celery_app.py @@ -0,0 +1,29 @@ +"""Celery приложение notifier-воркера.""" + +from celery import Celery + +from app.config import settings + +celery_app = Celery( + "worker_notifier", + broker=settings.RABBITMQ_URL, + backend=settings.REDIS_URL, + include=["app.tasks.notify"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Europe/Moscow", + enable_utc=True, + task_track_started=True, + task_routes={ + "notify.*": {"queue": "queue.notify"}, + "gpu.*": {"queue": "queue.gpu"}, + "index.*": {"queue": "queue.index"}, + "gost.*": {"queue": "queue.gost"}, + }, + task_acks_late=True, + result_expires=86400, +) diff --git a/services/worker-notifier/app/config.py b/services/worker-notifier/app/config.py new file mode 100644 index 0000000..e58aa98 --- /dev/null +++ b/services/worker-notifier/app/config.py @@ -0,0 +1,46 @@ +"""Конфигурация notifier-воркера.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # PostgreSQL + POSTGRES_HOST: str = "postgres" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "antiplagiator" + POSTGRES_USER: str = "antiplagiator" + POSTGRES_PASSWORD: str = "changeme" + + # Redis + REDIS_URL: str = "redis://redis:6379/0" + + # RabbitMQ + RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/" + + # SMTP + SMTP_HOST: str = "smtp.yandex.ru" + SMTP_PORT: int = 465 + SMTP_USER: str = "noreply@jze9.ru" + SMTP_PASSWORD: str = "changeme" + SMTP_FROM: str = "noreply@jze9.ru" + + # App + APP_URL: str = "https://academic.jze9.ru" + ENVIRONMENT: str = "development" + DEBUG: bool = False + + @property + def database_url_sync(self) -> str: + return ( + f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + +settings = Settings() diff --git a/services/worker-notifier/app/db.py b/services/worker-notifier/app/db.py new file mode 100644 index 0000000..0d6b682 --- /dev/null +++ b/services/worker-notifier/app/db.py @@ -0,0 +1,32 @@ +"""Подключение к PostgreSQL для notifier-воркера.""" + +from contextlib import contextmanager +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import settings + +engine = create_engine( + settings.database_url_sync, + pool_size=3, + max_overflow=5, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +@contextmanager +def db_session() -> Generator[Session, None, None]: + """Контекстный менеджер для сессии БД.""" + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/services/worker-notifier/app/email_sender.py b/services/worker-notifier/app/email_sender.py new file mode 100644 index 0000000..11d9964 --- /dev/null +++ b/services/worker-notifier/app/email_sender.py @@ -0,0 +1,165 @@ +"""Отправка email уведомлений через SMTP.""" + +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from app.config import settings + +logger = logging.getLogger(__name__) + +TASK_TYPE_SUBJECTS = { + "search": "Источники найдены — Академический помощник", + "plagiarism": "Проверка плагиата завершена — Академический помощник", + "summarize": "Краткое изложение готово — Академический помощник", + "gost": "Библиография отформатирована — Академический помощник", +} + + +class EmailSender: + """Отправщик email уведомлений.""" + + def _send(self, to_email: str, subject: str, html_body: str) -> None: + """ + Отправить email через SMTP SSL. + + Args: + to_email: Email получателя + subject: Тема письма + html_body: HTML тело письма + """ + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = settings.SMTP_FROM + msg["To"] = to_email + + msg.attach(MIMEText(html_body, "html", "utf-8")) + + try: + with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as smtp: + smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + smtp.send_message(msg) + logger.info(f"Email отправлен: {to_email!r}, тема: {subject!r}") + except smtplib.SMTPException as e: + logger.error(f"SMTP ошибка при отправке письма на {to_email!r}: {e}") + raise + + def send_task_done( + self, + user_email: str, + user_name: str, + task_id: str, + task_type: str, + summary: str, + app_url: str, + ) -> None: + """ + Отправить уведомление о завершении задачи. + + Args: + user_email: Email пользователя + user_name: Имя пользователя + task_id: ID завершённой задачи + task_type: Тип задачи (search, plagiarism, и т.д.) + summary: Краткое описание результата + app_url: Базовый URL приложения + """ + subject = TASK_TYPE_SUBJECTS.get(task_type, "Задача выполнена — Академический помощник") + task_url = f"{app_url}/tasks/{task_id}" + + html_body = f""" + + + + + + +
+ + +
+

+ Академический помощник +

+

academic.jze9.ru

+
+ + +
+

+ Здравствуйте, {user_name}! +

+ +

+ {summary} +

+ + + Открыть результат → + +
+ + +
+

+ Академический помощник · academic.jze9.ru
+ Это автоматическое письмо, не нужно отвечать на него. +

+
+
+ +""" + + self._send(user_email, subject, html_body) + + def send_verification(self, user_email: str, user_name: str, token: str, app_url: str) -> None: + """ + Отправить письмо для верификации email. + + Args: + user_email: Email для верификации + user_name: Имя пользователя + token: Токен верификации + app_url: Базовый URL приложения + """ + verify_url = f"{app_url}/verify-email/{token}" + subject = "Подтвердите email — Академический помощник" + + html_body = f""" + + + + + +
+ +
+

Подтвердите ваш email

+
+ +
+

+ Здравствуйте, {user_name}! +

+

+ Нажмите кнопку ниже, чтобы подтвердить ваш email и активировать аккаунт. +

+ + + Подтвердить email → + + +

+ Ссылка действительна 48 часов. Если вы не регистрировались, проигнорируйте это письмо. +

+
+
+ +""" + + self._send(user_email, subject, html_body) diff --git a/services/worker-notifier/app/tasks/__init__.py b/services/worker-notifier/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker-notifier/app/tasks/notify.py b/services/worker-notifier/app/tasks/notify.py new file mode 100644 index 0000000..1d3a56d --- /dev/null +++ b/services/worker-notifier/app/tasks/notify.py @@ -0,0 +1,141 @@ +"""Celery задачи отправки email уведомлений.""" + +import logging + +from celery.utils.log import get_task_logger + +from app.celery_app import celery_app +from app.config import settings +from app.db import db_session +from app.email_sender import EmailSender + +logger = get_task_logger(__name__) + + +@celery_app.task( + name="notify.send_task_done", + bind=True, + max_retries=3, + default_retry_delay=30, +) +def send_task_done(self, task_id: str) -> dict: + """ + Отправить email уведомление о завершении задачи. + + Args: + task_id: ID завершённой задачи + + Returns: + dict со статусом отправки + """ + from app.models import Task, User + + logger.info(f"Отправка уведомления для задачи {task_id!r}") + + try: + with db_session() as session: + task = session.get(Task, task_id) + if task is None: + logger.error(f"Задача {task_id!r} не найдена") + return {"status": "task_not_found"} + + user = session.get(User, task.user_id) + if user is None: + logger.error(f"Пользователь {task.user_id} не найден") + return {"status": "user_not_found"} + + # Формируем краткое описание результата + summary = _build_summary(task) + + sender = EmailSender() + sender.send_task_done( + user_email=user.email, + user_name=user.name, + task_id=task_id, + task_type=task.type, + summary=summary, + app_url=settings.APP_URL, + ) + + logger.info(f"Уведомление отправлено пользователю {user.email!r} для задачи {task_id!r}") + return {"status": "sent", "email": user.email} + + except Exception as exc: + logger.error(f"Ошибка отправки уведомления для задачи {task_id!r}: {exc}", exc_info=True) + raise self.retry(exc=exc, countdown=30) + + +def _build_summary(task) -> str: + """Сформировать краткое описание результата для email.""" + result = task.result or {} + + if task.type == "search": + count = len(result.get("sources", [])) + query = task.input_data.get("query", "") + return ( + f'Найдено {count} источников по запросу "{query[:80]}".' + f' Откройте результат, чтобы просмотреть список с ГОСТ-цитатами.' + ) + + elif task.type == "plagiarism": + similarity = result.get("overall_similarity", 0) + total = result.get("total_fragments", 0) + flagged = result.get("flagged_fragments", 0) + filename = task.input_data.get("filename", "") + + color = "#dc2626" if similarity > 30 else "#d97706" if similarity > 10 else "#16a34a" + label = "Высокий" if similarity > 30 else "Средний" if similarity > 10 else "Низкий" + + return ( + f'Проверка файла "{filename}" завершена.
' + f'Уровень схожести: {similarity:.1f}% ({label}).
' + f'Проверено {total} фрагментов, выявлено совпадений: {flagged}.' + ) + + elif task.type == "gost": + count = len(result.get("bibliography", [])) + style = result.get("style", "7.1") + return ( + f'Библиография из {count} источников' + f' отформатирована по ГОСТ {style}-2003.' + ) + + elif task.type == "summarize": + return "Краткое изложение успешно создано." + + return "Ваша задача выполнена." + + +@celery_app.task( + name="notify.send_verification", + bind=True, + max_retries=3, + default_retry_delay=60, +) +def send_verification(self, user_email: str, user_name: str, token: str) -> dict: + """ + Отправить письмо с подтверждением email. + + Args: + user_email: Email для верификации + user_name: Имя пользователя + token: Токен верификации + + Returns: + dict со статусом отправки + """ + logger.info(f"Отправка письма верификации на {user_email!r}") + + try: + sender = EmailSender() + sender.send_verification( + user_email=user_email, + user_name=user_name, + token=token, + app_url=settings.APP_URL, + ) + logger.info(f"Письмо верификации отправлено на {user_email!r}") + return {"status": "sent"} + except Exception as exc: + logger.error(f"Ошибка отправки верификации на {user_email!r}: {exc}", exc_info=True) + raise self.retry(exc=exc, countdown=60) diff --git a/services/worker-notifier/requirements.txt b/services/worker-notifier/requirements.txt new file mode 100644 index 0000000..aea779a --- /dev/null +++ b/services/worker-notifier/requirements.txt @@ -0,0 +1,5 @@ +celery==5.4.0 +redis==5.0.4 +sqlalchemy==2.0.30 +psycopg2-binary==2.9.9 +pydantic-settings==2.2.1