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 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-05-24 19:42:39 +05:00
commit 7758315632
120 changed files with 9500 additions and 0 deletions

View File

@@ -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 <Navigate to="/login" replace />;
if (!taskId) return <Navigate to="/cabinet" replace />;
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 (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
</div>
);
}
if (!task) {
return <Navigate to="/cabinet" replace />;
}
return (
<div className="space-y-6">
{/* Шапка */}
<div className="flex items-center gap-3">
<Link to="/cabinet" className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl font-semibold text-gray-900 capitalize">
{{
search: 'Поиск источников',
plagiarism: 'Проверка плагиата',
gost: 'ГОСТ библиография',
summarize: 'Краткое изложение',
}[task.type] || task.type}
</h1>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={task.status} />
<span className="text-xs text-gray-400">{task.id}</span>
</div>
</div>
</div>
{/* В процессе */}
{(task.status === 'queued' || task.status === 'processing') && (
<div className="bg-white rounded-xl border border-gray-100 p-10 text-center">
<Loader2 className="w-10 h-10 text-brand-400 animate-spin mx-auto mb-3" />
<p className="text-base font-medium text-gray-700">
{task.status === 'queued' ? 'Задача в очереди...' : 'Выполняется...'}
</p>
{task.queue_position && (
<p className="text-sm text-gray-400 mt-1">Позиция: {task.queue_position}</p>
)}
<p className="text-sm text-gray-400 mt-1">Вы получите email когда задача завершится</p>
</div>
)}
{/* Ошибка */}
{task.status === 'failed' && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<p className="font-medium text-red-700 mb-1">Задача завершилась с ошибкой</p>
<p className="text-sm text-red-500">{task.error}</p>
</div>
)}
{/* Результаты поиска */}
{task.status === 'done' && task.type === 'search' && task.result && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Запрос: <strong>«{(task.input_data as any).query}»</strong>
{' · '}{(task.result as SearchResultData).total} источников
</p>
{(task.result as SearchResultData).sources.map((source) => (
<SourceCard key={source.id} source={source} />
))}
</div>
)}
{/* Отчёт о плагиате */}
{task.status === 'done' && task.type === 'plagiarism' && task.result && (
<div className="bg-white rounded-xl border border-gray-100 p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">
Файл: {(task.input_data as any).filename}
</h2>
<PlagiarismReport data={task.result as PlagiarismResultData} />
</div>
)}
{/* ГОСТ библиография */}
{task.status === 'done' && task.type === 'gost' && task.result && (
<div className="bg-white rounded-xl border border-gray-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-900">
Список литературы (ГОСТ {(task.result as GostResultData).style}-2003)
</h2>
<button
onClick={() => {
const text = (task.result as GostResultData).bibliography
.map((e) => `${e.number}. ${e.citation}`)
.join('\n');
navigator.clipboard.writeText(text);
}}
className="px-3 py-1.5 text-xs font-medium text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 transition-colors"
>
Копировать всё
</button>
</div>
<div className="divide-y divide-gray-100">
{(task.result as GostResultData).bibliography.map((entry) => (
<GostCitation key={entry.doc_id} citation={entry.citation} number={entry.number} />
))}
</div>
</div>
)}
</div>
);
}