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,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<string | null>(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 (
<div className="space-y-6">
{/* Поисковая строка */}
<SearchBar
onSearch={handleSearch}
defaultValue={query}
isLoading={isLoading}
/>
<div className="flex gap-6">
{/* Боковая панель фильтров */}
<aside className="w-56 flex-shrink-0 hidden lg:block">
<div className="bg-white rounded-xl border border-gray-100 p-4 sticky top-24">
<div className="flex items-center gap-2 mb-4">
<Filter className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">Фильтры</span>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500 mb-1 block">Язык</label>
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">Все</option>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Год с</label>
<input
type="number"
value={yearFrom}
onChange={(e) => setYearFrom(e.target.value)}
placeholder="2000"
min={1900}
max={2100}
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Год по</label>
<input
type="number"
value={yearTo}
onChange={(e) => setYearTo(e.target.value)}
placeholder="2024"
min={1900}
max={2100}
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
</div>
</div>
</aside>
{/* Результаты */}
<main className="flex-1 min-w-0">
{/* Статус задачи */}
{task && task.status !== 'done' && (
<div className="bg-white rounded-xl border border-gray-100 p-6 text-center">
<div className="flex items-center justify-center gap-3 mb-2">
<Loader2 className="w-5 h-5 text-brand-500 animate-spin" />
<StatusBadge status={task.status} />
</div>
{task.queue_position && (
<p className="text-sm text-gray-500">Позиция в очереди: {task.queue_position}</p>
)}
{task.eta_seconds && (
<p className="text-sm text-gray-400">Ожидаемое время: ~{task.eta_seconds} сек</p>
)}
</div>
)}
{/* Результаты поиска */}
{task?.status === 'done' && result && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Найдено: <strong>{result.total}</strong> источников
</p>
{result.sources.map((source) => (
<SourceCard key={source.id} source={source} />
))}
</div>
)}
{/* Ошибка */}
{task?.status === 'failed' && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<p className="text-red-700 font-medium">Ошибка поиска</p>
<p className="text-red-500 text-sm mt-1">{task.error}</p>
</div>
)}
{/* Пустое состояние */}
{!query && !task && (
<div className="text-center py-16 text-gray-400">
<p>Введите запрос для поиска источников</p>
</div>
)}
{/* Не авторизован */}
{query && !isAuthenticated && (
<div className="bg-brand-50 border border-brand-200 rounded-xl p-6 text-center">
<p className="text-brand-700 font-medium mb-2">Необходима авторизация</p>
<p className="text-brand-600 text-sm">Войдите или зарегистрируйтесь для выполнения поиска</p>
</div>
)}
</main>
</div>
</div>
);
}