greeta commited on
Commit
4e285d0
·
verified ·
1 Parent(s): 542edad

Upload 8 files

Browse files
Files changed (8) hide show
  1. .env.example +12 -0
  2. DEPLOY_HF.md +216 -0
  3. Dockerfile +64 -0
  4. README.md +9 -0
  5. app.py +297 -0
  6. fipi_ai_scraper.py +515 -0
  7. requirements.txt +34 -0
  8. supabase_client.py +483 -0
.env.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SUPABASE_URL=https://sfajtyvvoyjunjwuenbk.supabase.co
2
+ SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNmYWp0eXZ2b3lqdW5qd3VlbmJrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4Mzg0MDQsImV4cCI6MjA4NjQxNDQwNH0.5ZjLsnIGJOXjm-pnWx3cgLPdXN0IIJpKWEPO9xxPAYk
3
+ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNmYWp0eXZ2b3lqdW5qd3VlbmJrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3MDgzODQwNCwiZXhwIjoyMDg2NDE0NDA0fQ.CbHsXnBwJwQGtKNcoTuXtFofF2p5sAr_f_Hzyf4uQd0
4
+
5
+ # Настройки парсера
6
+ MAX_PAGES=5
7
+ DELAY_MIN=2
8
+ DELAY_MAX=5
9
+
10
+ # Трансформеры кэш
11
+ TRANSFORMERS_CACHE=/tmp/transformers
12
+ HF_HOME=/tmp/huggingface
DEPLOY_HF.md ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Деплой на Hugging Face Spaces
2
+
3
+ ## Инструкция по запуску
4
+
5
+ ### Шаг 1: Создайте Space
6
+
7
+ 1. Перейдите на https://huggingface.co/spaces
8
+ 2. Нажмите **"Create new Space"**
9
+ 3. Заполните:
10
+ - **Name**: `fipi-parser-ege` (или любое другое)
11
+ - **License**: MIT
12
+ - **SDK**: **Docker**
13
+ - **Visibility**: Public (или Private)
14
+ 4. Нажмите **"Create Space"**
15
+
16
+ ### Шаг 2: Загрузите файлы
17
+
18
+ **Вариант A: Через Git**
19
+ ```bash
20
+ cd refined
21
+ git init
22
+ git add .
23
+ git commit -m "Initial commit"
24
+ git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE
25
+ git push -u origin main
26
+ ```
27
+
28
+ **Вариант B: Через веб-интерфейс**
29
+ 1. Откройте ваш Space на Hugging Face
30
+ 2. Перейдите в **"Files"**
31
+ 3. Нажмите **"Add file"** → **"Upload files"**
32
+ 4. Загрузите все файлы из проекта
33
+
34
+ ### Шаг 3: Настройте переменные окружения
35
+
36
+ 1. В панели Space перейдите в **"Settings"**
37
+ 2. Найдите **"Variables and secrets"**
38
+ 3. Добавьте:
39
+ - `SUPABASE_URL`: `https://your-project.supabase.co`
40
+ - `SUPABASE_KEY`: `your-anon-key`
41
+
42
+ ### Шаг 4: Запуск
43
+
44
+ Space автоматически соберётся и запустится!
45
+
46
+ **Время сборки:** 5-10 минут (загружается ruBERT модель)
47
+
48
+ ### Шаг 5: Использование API
49
+
50
+ После запуска ваш API будет доступен по адресу:
51
+ ```
52
+ https://YOUR_USERNAME-YOUR_SPACE.hf.space
53
+ ```
54
+
55
+ ## 📡 API Эндпоинты
56
+
57
+ ### 1. Проверка сочинения
58
+
59
+ ```bash
60
+ curl -X POST "https://YOUR_USERNAME-YOUR_SPACE.hf.space/grade" \
61
+ -H "Content-Type: application/json" \
62
+ -d '{
63
+ "essay": "В тексте поднимается проблема...",
64
+ "source": "Исходный текст..."
65
+ }'
66
+ ```
67
+
68
+ **Ответ:**
69
+ ```json
70
+ {
71
+ "total_score": 8,
72
+ "max_score": 9,
73
+ "percentage": 89,
74
+ "criteria": {
75
+ "k1": {"score": 1, "comment": "..."},
76
+ "k2": {"score": 3, "comment": "..."},
77
+ "k3": {"score": 2, "comment": "..."},
78
+ "k4": {"score": 1, "comment": "..."},
79
+ "k5": {"score": 1, "comment": "..."}
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### 2. Получить задания из Supabase
85
+
86
+ ```bash
87
+ curl "https://YOUR_USERNAME-YOUR_SPACE.hf.space/tasks"
88
+ ```
89
+
90
+ ### 3. Запустить парсер
91
+
92
+ ```bash
93
+ curl -X POST "https://YOUR_USERNAME-YOUR_SPACE.hf.space/parse" \
94
+ -H "Content-Type: application/json" \
95
+ -d '{"max_pages": 3}'
96
+ ```
97
+
98
+ ## 🏠 Главная страница
99
+
100
+ Откройте в браузере:
101
+ ```
102
+ https://YOUR_USERNAME-YOUR_SPACE.hf.space/docs
103
+ ```
104
+
105
+ Там будет Swagger UI с документацией API!
106
+
107
+ ## 📊 Структура проекта для HF
108
+
109
+ ```
110
+ refined/
111
+ ├── Dockerfile # Конфигурация Docker
112
+ ├── app.py # Основное API (FastAPI + ruBERT)
113
+ ├── requirements.txt # Python зависимости
114
+ ├── .env.example # Пример переменных
115
+ ├── fipi_ai_scraper.py # Парсер ФИПИ
116
+ ├── supabase_client.py # Клиент Supabase
117
+ └── README_HF.md # Эта инструкция
118
+ ```
119
+
120
+ ## ⚙️ Конфигурация
121
+
122
+ ### Dockerfile
123
+ - Python 3.10
124
+ - FastAPI + Uvicorn
125
+ - transformers (ruBERT)
126
+ - Порт: 7860
127
+
128
+ ### Переменные окружения
129
+ ```bash
130
+ SUPABASE_URL=https://your-project.supabase.co
131
+ SUPABASE_KEY=your-anon-key
132
+ TRANSFORMERS_CACHE=/tmp/transformers
133
+ HF_HOME=/tmp/huggingface
134
+ ```
135
+
136
+ ## 💰 Тарифы
137
+
138
+ **Бесплатный план:**
139
+ - ✅ CPU (2 vCPU)
140
+ - ✅ 16GB RAM
141
+ - ✅ 500MB хранилище
142
+ - ⚠️ Засыпает через 48 часов без активности
143
+
144
+ **Pro план ($9/мес):**
145
+ - ✅ Не засыпает
146
+ - ✅ Больше ресурсов
147
+ - ✅ Приватные Spaces
148
+
149
+ ## 🔧 Troubleshooting
150
+
151
+ ### Space не запускается
152
+ 1. Проверьте логи в панели **"Logs"**
153
+ 2. Убедитесь, что Dockerfile корректен
154
+ 3. Проверьте зависимости в requirements.txt
155
+
156
+ ### Ошибка памяти
157
+ ruBERT требует ~2GB RAM. Если не хватает:
158
+ - Используйте Pro план
159
+ - Или уберите transformers из requirements.txt
160
+
161
+ ### Supabase не подключается
162
+ 1. Проверьте переменные в Settings → Variables
163
+ 2. Убедитесь, что таблица tasks создана
164
+ 3. Проверьте URL и ключ
165
+
166
+ ## 📝 Примеры использования
167
+
168
+ ### Python клиент
169
+ ```python
170
+ import requests
171
+
172
+ API_URL = "https://YOUR_USERNAME-YOUR_SPACE.hf.space"
173
+
174
+ # Проверка сочинения
175
+ response = requests.post(
176
+ f"{API_URL}/grade",
177
+ json={
178
+ "essay": "В тексте подн��мается проблема...",
179
+ "source": "Исходный текст..."
180
+ }
181
+ )
182
+ print(response.json())
183
+
184
+ # Получить задания
185
+ response = requests.get(f"{API_URL}/tasks")
186
+ print(response.json())
187
+ ```
188
+
189
+ ### JavaScript клиент
190
+ ```javascript
191
+ const API_URL = "https://YOUR_USERNAME-YOUR_SPACE.hf.space";
192
+
193
+ // Проверка сочинения
194
+ const response = await fetch(`${API_URL}/grade`, {
195
+ method: "POST",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({
198
+ essay: "В тексте поднимается проблема...",
199
+ source: "Исходный текст..."
200
+ })
201
+ });
202
+
203
+ const result = await response.json();
204
+ console.log(result);
205
+ ```
206
+
207
+ ## 🎉 Готово!
208
+
209
+ Ваш сервис для проверки сочинений ЕГЭ и парсинга заданий ФИПИ работает на Hugging Face Spaces!
210
+
211
+ ---
212
+
213
+ **Ссылки:**
214
+ - Документация HF Spaces: https://huggingface.co/docs/hub/spaces
215
+ - Docker SDK: https://huggingface.co/docs/hub/spaces-sdks-docker
216
+ - FastAPI: https://fastapi.tiangolo.com/
Dockerfile ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile для ЕГЭ Парсера ФИПИ
2
+ # Multi-stage build для оптимизации размера
3
+
4
+ FROM python:3.10-slim as base
5
+
6
+ # Рабочая директория
7
+ WORKDIR /app
8
+
9
+ # Переменные окружения
10
+ ENV PYTHONDONTWRITEBYTECODE=1 \
11
+ PYTHONUNBUFFERED=1 \
12
+ PIP_NO_CACHE_DIR=1 \
13
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
14
+ TRANSFORMERS_CACHE=/tmp/transformers \
15
+ HF_HOME=/tmp/huggingface
16
+
17
+ # Установка системных зависимостей
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ build-essential \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # Копирование requirements
23
+ COPY requirements.txt .
24
+
25
+ # Установка Python зависимостей (кэширование слоя)
26
+ RUN pip install --no-cache-dir -r requirements.txt
27
+
28
+ # Копирование кода
29
+ COPY . .
30
+
31
+ # Загрузка spaCy модели
32
+ RUN python -m spacy download ru_core_news_md || true
33
+
34
+ # Порт по умолчанию
35
+ EXPOSE 7860
36
+
37
+ # Команда запуска
38
+ CMD ["python", "app.py"]
39
+
40
+ # ============================================================
41
+ # Development stage (опционально)
42
+ # ============================================================
43
+
44
+ FROM base as dev
45
+
46
+ # Установка dev зависимостей
47
+ RUN pip install pytest pytest-cov black flake8 mypy
48
+
49
+ # Команда для разработки
50
+ CMD ["python", "-m", "uvicorn", "app:app", "--reload", "--host", "0.0.0.0", "--port", "7860"]
51
+
52
+ # ============================================================
53
+ # Production stage (опционально)
54
+ # ============================================================
55
+
56
+ FROM base as prod
57
+
58
+ # Создание не-root пользователя
59
+ RUN useradd -m -u 1000 appuser && \
60
+ chown -R appuser:appuser /app
61
+ USER appuser
62
+
63
+ # Production команда
64
+ CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "4"]
README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ФИПИ Скрапер API
3
+ emoji: 📝
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
app.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ЕГЭ Эксперт - API для проверки сочинений и парсинга заданий
3
+ Объединяет ruBERT scraper и ФИПИ парсер
4
+ """
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from pydantic import BaseModel
9
+ from typing import Optional, List, Dict
10
+ import torch
11
+ from transformers import AutoTokenizer, AutoModel
12
+ import re
13
+ import json
14
+ import os
15
+ from dotenv import load_dotenv
16
+
17
+ # Загружаем переменные окружения
18
+ load_dotenv()
19
+
20
+ app = FastAPI(
21
+ title="ЕГЭ Эксперт API",
22
+ description="Проверка сочинений ЕГЭ + парсинг заданий ФИПИ",
23
+ version="2.0.0"
24
+ )
25
+
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+ # ============================================================
34
+ # ЗАГРУЗКА ruBERT
35
+ # ============================================================
36
+
37
+ MODEL_NAME = "DeepPavlov/rubert-base-cased-sentence"
38
+ tokenizer = None
39
+ model = None
40
+
41
+ def load_model():
42
+ global tokenizer, model
43
+ print("Loading ruBERT model...")
44
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
45
+ model = AutoModel.from_pretrained(MODEL_NAME)
46
+ model.eval()
47
+ print("ruBERT loaded!")
48
+
49
+ @app.on_event("startup")
50
+ async def startup():
51
+ load_model()
52
+
53
+ # ============================================================
54
+ # МОДЕЛИ ДАННЫХ
55
+ # ============================================================
56
+
57
+ class EssayRequest(BaseModel):
58
+ essay: str
59
+ source: Optional[str] = ""
60
+
61
+ class TaskRequest(BaseModel):
62
+ url: Optional[str] = ""
63
+ max_pages: int = 3
64
+
65
+ class SupabaseConfig(BaseModel):
66
+ supabase_url: str
67
+ supabase_key: str
68
+
69
+ # ============================================================
70
+ # УТИЛИТЫ
71
+ # ============================================================
72
+
73
+ def normalize(text: str) -> str:
74
+ return text.lower().replace("ё", "е").strip()
75
+
76
+ def count_words(text: str) -> int:
77
+ return len([w for w in text.strip().split() if w])
78
+
79
+ def get_paragraphs(text: str) -> list:
80
+ return [p.strip() for p in re.split(r'\n+', text) if p.strip()]
81
+
82
+ def get_sentences(text: str) -> list:
83
+ return [s.strip() for s in re.split(r'[.!?]+', text) if s.strip()]
84
+
85
+ def get_embedding(text: str) -> torch.Tensor:
86
+ inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512, padding=True)
87
+ with torch.no_grad():
88
+ outputs = model(**inputs)
89
+ token_embeddings = outputs.last_hidden_state
90
+ attention_mask = inputs["attention_mask"]
91
+ mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
92
+ embedding = torch.sum(token_embeddings * mask_expanded, 1) / torch.clamp(mask_expanded.sum(1), min=1e-9)
93
+ return embedding[0]
94
+
95
+ def cosine_similarity(a: torch.Tensor, b: torch.Tensor) -> float:
96
+ return torch.nn.functional.cosine_similarity(a.unsqueeze(0), b.unsqueeze(0)).item()
97
+
98
+ # ============================================================
99
+ # КРИТЕРИИ ЕГЭ
100
+ # ============================================================
101
+
102
+ K1_PHRASES = ["проблем", "автор поднимает", "автор рассматривает", "текст посвящен"]
103
+ K2_EXAMPLE_PHRASES = ["например", "автор пишет", "автор описывает", "в тексте"]
104
+ K2_LINK_PHRASES = ["таким образом", "следовательно", "оба примера", "кроме того"]
105
+ K3_OPINION_PHRASES = ["я считаю", "я думаю", "по моему мнению", "я согласен"]
106
+ K3_ARG_PHRASES = ["потому что", "так как", "литература", "в романе", "в повести"]
107
+
108
+ def check_k1(essay: str, has_source: bool, relevance: float = 0.5) -> dict:
109
+ n = normalize(essay)
110
+ found = [p for p in K1_PHRASES if p in n]
111
+
112
+ if has_source:
113
+ if len(found) >= 1 or relevance > 0.4:
114
+ return {"score": 1, "comment": "Позиция автора сформулирована."}
115
+ return {"score": 0, "comment": "Позиция автора не сформулирована."}
116
+ else:
117
+ if len(found) >= 1:
118
+ return {"score": 1, "comment": "Проблема сформулирована."}
119
+ return {"score": 0, "comment": "Проблема не сформулирована."}
120
+
121
+ def check_k2(essay: str, has_source: bool) -> dict:
122
+ n = normalize(essay)
123
+ sentences = get_sentences(essay)
124
+
125
+ example_sentences = [s for s in sentences if any(p in normalize(s) for p in K2_EXAMPLE_PHRASES)]
126
+ has_link = any(p in n for p in K2_LINK_PHRASES)
127
+
128
+ if len(example_sentences) >= 2 and has_link:
129
+ return {"score": 3, "comment": "Два примера с пояснением и связью."}
130
+ elif len(example_sentences) >= 2:
131
+ return {"score": 2, "comment": "Два примера без связи."}
132
+ elif len(example_sentences) >= 1:
133
+ return {"score": 1, "comment": "Один пример."}
134
+ return {"score": 0, "comment": "Нет примеров."}
135
+
136
+ def check_k3(essay: str) -> dict:
137
+ n = normalize(essay)
138
+
139
+ has_opinion = any(p in n for p in K3_OPINION_PHRASES)
140
+ has_arg = any(p in n for p in K3_ARG_PHRASES)
141
+
142
+ if has_opinion and has_arg:
143
+ return {"score": 2, "comment": "Позиция выражена и обоснована."}
144
+ elif has_opinion:
145
+ return {"score": 1, "comment": "Позиция выражена."}
146
+ return {"score": 0, "comment": "Позиция не выражена."}
147
+
148
+ def check_k4(essay: str) -> dict:
149
+ if count_words(essay) < 50:
150
+ return {"score": 0, "comment": "Текст слишком короткий."}
151
+ return {"score": 1, "comment": "Ошибок нет."}
152
+
153
+ def check_k5(essay: str) -> dict:
154
+ paragraphs = get_paragraphs(essay)
155
+
156
+ if len(paragraphs) >= 5:
157
+ return {"score": 2, "comment": "Структура соблюдена."}
158
+ elif len(paragraphs) >= 3:
159
+ return {"score": 1, "comment": "Структура частична."}
160
+ return {"score": 0, "comment": "Нет абзацев."}
161
+
162
+ # ============================================================
163
+ # API ЭНДПОИНТЫ
164
+ # ============================================================
165
+
166
+ @app.get("/")
167
+ async def root():
168
+ return {
169
+ "message": "ЕГЭ Эксперт API",
170
+ "version": "2.0.0",
171
+ "endpoints": [
172
+ "POST /grade - Проверка сочинения",
173
+ "GET /tasks - Получить задания из БД",
174
+ "POST /parse - Запустить парсер"
175
+ ]
176
+ }
177
+
178
+ @app.post("/grade")
179
+ async def grade_essay(request: EssayRequest):
180
+ """Проверка сочинения ЕГЭ"""
181
+
182
+ essay = request.essay
183
+ source = request.source or ""
184
+ has_source = len(source) > 10
185
+
186
+ # Семантическая близость
187
+ relevance = 0.5
188
+ if has_source:
189
+ try:
190
+ emb_essay = get_embedding(essay[:512])
191
+ emb_source = get_embedding(source[:512])
192
+ relevance = cosine_similarity(emb_essay, emb_source)
193
+ except:
194
+ pass
195
+
196
+ # Проверка по критериям
197
+ k1 = check_k1(essay, has_source, relevance)
198
+ k2 = check_k2(essay, has_source)
199
+ k3 = check_k3(essay)
200
+ k4 = check_k4(essay)
201
+ k5 = check_k5(essay)
202
+
203
+ total = k1["score"] + k2["score"] + k3["score"] + k4["score"] + k5["score"]
204
+ max_score = 9
205
+
206
+ return {
207
+ "total_score": total,
208
+ "max_score": max_score,
209
+ "percentage": round(total / max_score * 100),
210
+ "criteria": {
211
+ "k1": k1,
212
+ "k2": k2,
213
+ "k3": k3,
214
+ "k4": k4,
215
+ "k5": k5
216
+ },
217
+ "stats": {
218
+ "words": count_words(essay),
219
+ "paragraphs": len(get_paragraphs(essay)),
220
+ "sentences": len(get_sentences(essay))
221
+ }
222
+ }
223
+
224
+ @app.get("/tasks")
225
+ async def get_tasks():
226
+ """Получить задания из Supabase"""
227
+
228
+ supabase_url = os.getenv("SUPABASE_URL")
229
+ supabase_key = os.getenv("SUPABASE_KEY")
230
+
231
+ if not supabase_url or not supabase_key:
232
+ return {"error": "Supabase не настроен", "tasks": []}
233
+
234
+ try:
235
+ import requests
236
+ response = requests.get(
237
+ f"{supabase_url}/rest/v1/tasks?limit=100",
238
+ headers={
239
+ "apikey": supabase_key,
240
+ "Authorization": f"Bearer {supabase_key}"
241
+ },
242
+ timeout=10
243
+ )
244
+
245
+ if response.status_code == 200:
246
+ tasks = response.json()
247
+ return {"count": len(tasks), "tasks": tasks}
248
+ else:
249
+ return {"error": f"Ошибка {response.status_code}", "tasks": []}
250
+ except Exception as e:
251
+ return {"error": str(e), "tasks": []}
252
+
253
+ @app.post("/parse")
254
+ async def parse_tasks(request: TaskRequest):
255
+ """Запустить парсер заданий"""
256
+
257
+ supabase_url = os.getenv("SUPABASE_URL")
258
+ supabase_key = os.getenv("SUPABASE_KEY")
259
+
260
+ if not supabase_url or not supabase_key:
261
+ return {"error": "Supabase не настроен"}
262
+
263
+ # Импортируем парсер
264
+ try:
265
+ from fipi_ai_scraper import parse_all_sources
266
+ tasks = parse_all_sources(max_pages=request.max_pages)
267
+
268
+ # Сохраняем в Supabase
269
+ if tasks:
270
+ import requests
271
+ saved = 0
272
+ for task in tasks:
273
+ resp = requests.post(
274
+ f"{supabase_url}/rest/v1/tasks",
275
+ headers={
276
+ "apikey": supabase_key,
277
+ "Authorization": f"Bearer {supabase_key}",
278
+ "Content-Type": "application/json"
279
+ },
280
+ json=task,
281
+ timeout=10
282
+ )
283
+ if resp.status_code in [200, 201]:
284
+ saved += 1
285
+
286
+ return {"message": f"Сохранено {saved} заданий", "count": saved}
287
+ return {"message": "Задания не найдены"}
288
+ except Exception as e:
289
+ return {"error": str(e)}
290
+
291
+ # ============================================================
292
+ # ЗАПУСК
293
+ # ============================================================
294
+
295
+ if __name__ == "__main__":
296
+ import uvicorn
297
+ uvicorn.run(app, host="0.0.0.0", port=7860)
fipi_ai_scraper.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Scraper для заданий ЕГЭ по русскому языку
3
+ Использует ScrapeGraphAI для интеллектуального парсинга
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import time
9
+ from typing import List, Dict, Optional
10
+ from datetime import datetime
11
+ import requests
12
+ from bs4 import BeautifulSoup
13
+ from dotenv import load_dotenv
14
+ import os
15
+
16
+ # Загружаем переменные окружения
17
+ load_dotenv()
18
+
19
+ # ============================================================
20
+ # КОНФИГУРАЦИЯ
21
+ # ============================================================
22
+
23
+ SOURCES = {
24
+ "fipi": {
25
+ "name": "ФИПИ",
26
+ "base_url": "https://fipi.ru/ege/demonstracionnye-varianty-i-specifikacii",
27
+ "enabled": False # ФИПИ блокирует запросы
28
+ },
29
+ "examer": {
30
+ "name": "Examer",
31
+ "base_url": "https://examer.ru/ege_po_russkomu_yazyku/zadanie",
32
+ "enabled": True
33
+ },
34
+ "neofamily": {
35
+ "name": "Neofamily",
36
+ "base_url": "https://neofamily.ru/ege-russkiy-yazyk",
37
+ "enabled": True
38
+ }
39
+ }
40
+
41
+ MAX_PAGES = 5
42
+ DELAY_MIN = 2
43
+ DELAY_MAX = 5
44
+
45
+ # ============================================================
46
+ # МОДЕЛИ ДАННЫХ (Pydantic schemas)
47
+ # ============================================================
48
+
49
+ from pydantic import BaseModel, Field
50
+
51
+
52
+ class TaskSchema(BaseModel):
53
+ """Схема задания ЕГЭ"""
54
+ task_id: str = Field(..., description="Уникальный ID задания")
55
+ topic: str = Field(default="Русский язык", description="Тема задания")
56
+ condition: str = Field(..., description="Условие задания")
57
+ content: str = Field(..., description="Содержимое задания")
58
+ answer_format: str = Field(default="не определено", description="Формат ответа")
59
+ source_name: str = Field(..., description="Источник")
60
+ structure: Dict = Field(default_factory=dict, description="Структура задания")
61
+ parsed_at: str = Field(default_factory=lambda: datetime.now().isoformat())
62
+
63
+
64
+ class TopicSchema(BaseModel):
65
+ """Схема темы"""
66
+ name: str
67
+ confidence: float
68
+ keywords: List[str] = []
69
+
70
+
71
+ # ============================================================
72
+ # NLP ПРОЦЕССОР (spaCy + Hugging Face)
73
+ # ============================================================
74
+
75
+ class NLPProcessor:
76
+ """Обработка текста с помощью NLP"""
77
+
78
+ def __init__(self):
79
+ self.nlp = None
80
+ self.classifier = None
81
+ self._loaded = False
82
+
83
+ def load_models(self):
84
+ """Загрузка моделей"""
85
+ try:
86
+ import spacy
87
+ print("Загрузка spaCy модели для русского языка...")
88
+ self.nlp = spacy.load("ru_core_news_md")
89
+ print("[OK] spaCy загружен")
90
+ except Exception as e:
91
+ print(f"[WARN] spaCy не загружен: {e}")
92
+
93
+ try:
94
+ from transformers import pipeline
95
+ print("Загрузка классификатора ruBERT...")
96
+ self.classifier = pipeline(
97
+ "text-classification",
98
+ model="DeepPavlov/rubert-base-cased-sentence",
99
+ top_k=None
100
+ )
101
+ print("[OK] ruBERT загружен")
102
+ except Exception as e:
103
+ print(f"[WARN] ruBERT не загружен: {e}")
104
+
105
+ self._loaded = True
106
+
107
+ def analyze_topic(self, text: str) -> TopicSchema:
108
+ """Определение темы задания"""
109
+ topics_keywords = {
110
+ "Орфография": ["правопис", "орфограм", "корень", "приставк", "суффикс", "окончани"],
111
+ "Пунктуация": ["запят", "тире", "двоеточ", "пунктуаци", "знак"],
112
+ "Морфология": ["морфем", "морфолог", "часть речи", "падеж", "число", "род"],
113
+ "Синтаксис": ["синтаксис", "предложени", "подлежащ", "сказуем", "член"],
114
+ "Культура речи": ["норм", "ударени", "произнош", "литератур"],
115
+ "Лексика": ["лексическ", "значени", "синоним", "антоним", "фразеолог"],
116
+ "Грамматика": ["грамматик", "ошибк", "постро", "форм"]
117
+ }
118
+
119
+ text_lower = text.lower()
120
+ best_topic = "Русский язык"
121
+ best_count = 0
122
+
123
+ for topic, keywords in topics_keywords.items():
124
+ count = sum(1 for kw in keywords if kw in text_lower)
125
+ if count > best_count:
126
+ best_topic = topic
127
+ best_count = count
128
+
129
+ return TopicSchema(
130
+ name=best_topic,
131
+ confidence=min(best_count / 3.0, 1.0),
132
+ keywords=[kw for kw in topics_keywords.get(best_topic, []) if kw in text_lower]
133
+ )
134
+
135
+ def analyze_structure(self, text: str) -> Dict:
136
+ """Анализ структуры текста"""
137
+ doc = self.nlp(text) if self.nlp else None
138
+
139
+ sentences = [s.text.strip() for s in doc.sents] if doc else text.split('.')
140
+ words = text.split()
141
+
142
+ return {
143
+ "sentences_count": len(sentences),
144
+ "words_count": len(words),
145
+ "unique_words": len(set(w.lower() for w in words)),
146
+ "avg_sentence_length": len(words) / max(len(sentences), 1),
147
+ "has_paragraphs": "\n\n" in text
148
+ }
149
+
150
+ def determine_answer_format(self, text: str) -> str:
151
+ """Определение формата ответа"""
152
+ text_lower = text.lower()
153
+
154
+ if any(x in text_lower for x in ["одно слово", "одним словом", "слово"]):
155
+ return "слово"
156
+ elif any(x in text_lower for x in ["цифра", "число", "ответ"]):
157
+ return "цифра"
158
+ elif any(x in text_lower for x in ["последователь", "цифр", "порядк"]):
159
+ return "последовательность"
160
+ elif any(x in text_lower for x in ["соответств", "соотнес", "пар"]):
161
+ return "соответствие"
162
+ elif any(x in text_lower for x in ["выбор", "вариант", "отметь"]):
163
+ return "выбор"
164
+ elif any(x in text_lower for x in ["запиш", "встав", "пропущ"]):
165
+ return "вставка"
166
+ else:
167
+ return "не определено"
168
+
169
+
170
+ # ============================================================
171
+ # FEEDER ROBOT (Навигация по каталогу)
172
+ # ============================================================
173
+
174
+ class FeederRobot:
175
+ """Робот для обхода страниц каталога заданий"""
176
+
177
+ def __init__(self, source: str, config: Dict):
178
+ self.source = source
179
+ self.config = config
180
+ self.urls_queue = []
181
+ self.session = requests.Session()
182
+ self.session.headers.update({
183
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
184
+ })
185
+
186
+ def collect_urls(self, max_pages: int = MAX_PAGES) -> List[str]:
187
+ """Сбор URL-адресов заданий"""
188
+ print(f"\n[Feeder] Сбор URL с {self.config['name']}...")
189
+
190
+ if self.source == "examer":
191
+ return self._collect_examer_urls(max_pages)
192
+ elif self.source == "neofamily":
193
+ return self._collect_neofamily_urls(max_pages)
194
+
195
+ return []
196
+
197
+ def _collect_examer_urls(self, max_pages: int) -> List[str]:
198
+ """Сбор URL с examer.ru"""
199
+ urls = []
200
+ base_url = self.config["base_url"]
201
+
202
+ for page in range(1, max_pages + 1):
203
+ url = f"{base_url}/{page}"
204
+ try:
205
+ print(f" Страница {page}: {url}")
206
+ response = self.session.get(url, timeout=10)
207
+
208
+ if response.status_code == 200:
209
+ soup = BeautifulSoup(response.text, 'lxml')
210
+
211
+ # Ищем ссылки на задания
212
+ links = soup.select('a[href*="/zadanie/"]')
213
+ for link in links:
214
+ href = link.get('href', '')
215
+ if href and href not in urls:
216
+ urls.append(href)
217
+
218
+ time.sleep(DELAY_MIN)
219
+ else:
220
+ print(f" [WARN] Статус {response.status_code}")
221
+ break
222
+
223
+ except Exception as e:
224
+ print(f" [ERROR] Ошибка: {e}")
225
+ break
226
+
227
+ print(f" [OK] Найдено {len(urls)} URL")
228
+ return urls
229
+
230
+ def _collect_neofamily_urls(self, max_pages: int) -> List[str]:
231
+ """Сбор URL с neofamily.ru"""
232
+ urls = []
233
+ base_url = self.config["base_url"]
234
+
235
+ # Neofamily использует другую структуру
236
+ try:
237
+ response = self.session.get(base_url, timeout=10)
238
+ if response.status_code == 200:
239
+ soup = BeautifulSoup(response.text, 'lxml')
240
+ links = soup.select('a[href*="/task/"]')
241
+ for link in links[:max_pages * 10]:
242
+ href = link.get('href', '')
243
+ if href and href.startswith('http'):
244
+ urls.append(href)
245
+ except Exception as e:
246
+ print(f" [ERROR] Neofamily: {e}")
247
+
248
+ print(f" [OK] Найдено {len(urls)} URL")
249
+ return urls
250
+
251
+
252
+ # ============================================================
253
+ # FINISHER ROBOT (Глубокий парсинг)
254
+ # ============================================================
255
+
256
+ class FinisherRobot:
257
+ """Робот для глубокого парсинга заданий"""
258
+
259
+ def __init__(self, nlp_processor: NLPProcessor):
260
+ self.nlp = nlp_processor
261
+ self.session = requests.Session()
262
+ self.session.headers.update({
263
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
264
+ })
265
+
266
+ def parse_task(self, url: str, source: str) -> Optional[Dict]:
267
+ """Парсинг одного задания"""
268
+ try:
269
+ print(f" [Finisher] Парсинг: {url[:80]}...")
270
+ response = self.session.get(url, timeout=10)
271
+
272
+ if response.status_code != 200:
273
+ return None
274
+
275
+ soup = BeautifulSoup(response.text, 'lxml')
276
+
277
+ # Извлекаем условие и контент
278
+ condition = self._extract_condition(soup, source)
279
+ content = self._extract_content(soup, source)
280
+
281
+ if not condition and not content:
282
+ return None
283
+
284
+ # Генерируем ID
285
+ task_id = f"{source}_{abs(hash(url)) % 100000}"
286
+
287
+ # NLP анализ
288
+ full_text = f"{condition} {content}"
289
+ topic_info = self.nlp.analyze_topic(full_text)
290
+ structure = self.nlp.analyze_structure(full_text)
291
+ answer_format = self.nlp.determine_answer_format(full_text)
292
+
293
+ return {
294
+ "task_id": task_id,
295
+ "topic": topic_info.name,
296
+ "condition": condition[:2000] if condition else "",
297
+ "content": content[:2000] if content else "",
298
+ "answer_format": answer_format,
299
+ "source_name": source,
300
+ "structure": structure,
301
+ "parsed_at": datetime.now().isoformat(),
302
+ "url": url
303
+ }
304
+
305
+ except Exception as e:
306
+ print(f" [ERROR] Ошибка парсинга: {e}")
307
+ return None
308
+
309
+ def _extract_condition(self, soup: BeautifulSoup, source: str) -> str:
310
+ """Извлечение условия задания"""
311
+ if source == "examer":
312
+ # Examer использует специфичные классы
313
+ condition_blocks = soup.select('.task-description, .condition, p:first-child')
314
+ if condition_blocks:
315
+ return condition_blocks[0].get_text(strip=True)
316
+
317
+ if source == "neofamily":
318
+ task_blocks = soup.select('.task-text, .question-text')
319
+ if task_blocks:
320
+ return task_blocks[0].get_text(strip=True)
321
+
322
+ # fallback
323
+ paragraphs = soup.find_all('p')
324
+ return paragraphs[0].get_text(strip=True) if paragraphs else ""
325
+
326
+ def _extract_content(self, soup: BeautifulSoup, source: str) -> str:
327
+ """Извлечение содержимого задания"""
328
+ if source == "examer":
329
+ content_blocks = soup.select('.task-content, .example, .text-block')
330
+ if content_blocks:
331
+ return '\n'.join([b.get_text(strip=True) for b in content_blocks[:3]])
332
+
333
+ if source == "neofamily":
334
+ content_blocks = soup.select('.content, .passage')
335
+ if content_blocks:
336
+ return '\n'.join([b.get_text(strip=True) for b in content_blocks[:3]])
337
+
338
+ return ""
339
+
340
+
341
+ # ============================================================
342
+ # SCRAPEGRAPH AI ИНТЕГРАЦИЯ
343
+ # ============================================================
344
+
345
+ class ScrapeGraphAIProcessor:
346
+ """Интеграция со ScrapeGraphAI для умного парсинга"""
347
+
348
+ def __init__(self):
349
+ self.enabled = False
350
+ try:
351
+ from scrapegraphai.graphs import SmartScraperGraph
352
+ self.SmartScraperGraph = SmartScraperGraph
353
+ self.enabled = True
354
+ print("[OK] ScrapeGraphAI доступен")
355
+ except ImportError:
356
+ print("[WARN] ScrapeGraphAI не установлен")
357
+
358
+ def parse_with_ai(self, url: str, prompt: str) -> Optional[Dict]:
359
+ """Парсинг с использованием AI"""
360
+ if not self.enabled:
361
+ return None
362
+
363
+ try:
364
+ graph_config = {
365
+ "llm": {
366
+ "model": "ollama/llama2",
367
+ "temperature": 0,
368
+ "format": "json"
369
+ },
370
+ "embeddings": {
371
+ "model": "ollama/nomic-embed-text"
372
+ }
373
+ }
374
+
375
+ smart_scraper = self.SmartScraperGraph(
376
+ prompt=prompt,
377
+ source=url,
378
+ config=graph_config
379
+ )
380
+
381
+ result = smart_scraper.run()
382
+ return result
383
+
384
+ except Exception as e:
385
+ print(f" [ERROR] ScrapeGraphAI: {e}")
386
+ return None
387
+
388
+
389
+ # ============================================================
390
+ # ОСНОВНОЙ ПАРСЕР
391
+ # ============================================================
392
+
393
+ class FipiAIParser:
394
+ """Основной парсер с интеграцией всех компонентов"""
395
+
396
+ def __init__(self):
397
+ self.nlp = NLPProcessor()
398
+ self.nlp.load_models()
399
+
400
+ self.feeder = None
401
+ self.finisher = FinisherRobot(self.nlp)
402
+ self.scrapegraph = ScrapeGraphAIProcessor()
403
+
404
+ self.parsed_tasks = []
405
+
406
+ def parse_source(self, source: str, max_pages: int = MAX_PAGES) -> List[Dict]:
407
+ """Парсинг одного источника"""
408
+ if source not in SOURCES or not SOURCES[source]["enabled"]:
409
+ print(f"[SKIP] {source} отключен")
410
+ return []
411
+
412
+ config = SOURCES[source]
413
+ print(f"\n{'='*50}")
414
+ print(f"Парсинг источника: {config['name']}")
415
+ print(f"{'='*50}")
416
+
417
+ # Feeder: сбор URL
418
+ self.feeder = FeederRobot(source, config)
419
+ urls = self.feeder.collect_urls(max_pages)
420
+
421
+ if not urls:
422
+ print(f"[WARN] URL не найдены")
423
+ return []
424
+
425
+ # Finisher: парсинг заданий
426
+ tasks = []
427
+ for i, url in enumerate(urls[:20], 1): # Ограничим 20 для теста
428
+ print(f"\n[{i}/{len(urls)}]")
429
+ task = self.finisher.parse_task(url, source)
430
+ if task:
431
+ tasks.append(task)
432
+ self.parsed_tasks.append(task)
433
+
434
+ time.sleep(DELAY_MIN)
435
+
436
+ print(f"\n[OK] {config['name']}: найдено {len(tasks)} заданий")
437
+ return tasks
438
+
439
+ def parse_all_sources(self, max_pages: int = MAX_PAGES) -> List[Dict]:
440
+ """Парсинг всех источников"""
441
+ all_tasks = []
442
+
443
+ for source in SOURCES:
444
+ tasks = self.parse_source(source, max_pages)
445
+ all_tasks.extend(tasks)
446
+
447
+ return all_tasks
448
+
449
+ def save_to_jsonl(self, tasks: List[Dict], filename: str = "fipi_ai_tasks.jsonl"):
450
+ """Сохранение в JSONL формат"""
451
+ with open(filename, 'w', encoding='utf-8') as f:
452
+ for task in tasks:
453
+ f.write(json.dumps(task, ensure_ascii=False) + '\n')
454
+ print(f"[OK] Сохранено {len(tasks)} заданий в {filename}")
455
+
456
+ def save_to_supabase(self, tasks: List[Dict]) -> Dict:
457
+ """Сохранение в Supabase"""
458
+ from supabase_client import save_tasks_batch
459
+ return save_tasks_batch(tasks)
460
+
461
+
462
+ # ============================================================
463
+ # ЗАПУСК
464
+ # ============================================================
465
+
466
+ def main():
467
+ """Точка входа"""
468
+ print("="*60)
469
+ print("AI Scraper для заданий ЕГЭ по русскому языку")
470
+ print("="*60)
471
+
472
+ parser = FipiAIParser()
473
+
474
+ # Парсинг
475
+ tasks = parser.parse_all_sources(max_pages=MAX_PAGES)
476
+
477
+ if not tasks:
478
+ print("\n[WARN] Задания не найдены. Используем тестовые данные...")
479
+ from generate_sample_data import generate_sample_tasks
480
+ tasks = generate_sample_tasks()
481
+
482
+ # Сохранение
483
+ parser.save_to_jsonl(tasks)
484
+
485
+ # Supabase (если настроен)
486
+ if os.getenv("SUPABASE_URL"):
487
+ parser.save_to_supabase(tasks)
488
+
489
+ # Статистика
490
+ print("\n" + "="*60)
491
+ print("СТАТИСТИКА")
492
+ print("="*60)
493
+ print(f"Всего заданий: {len(tasks)}")
494
+
495
+ topics = {}
496
+ for task in tasks:
497
+ topic = task.get("topic", "Русский язык")
498
+ topics[topic] = topics.get(topic, 0) + 1
499
+
500
+ print("\nТемы:")
501
+ for topic, count in sorted(topics.items(), key=lambda x: -x[1]):
502
+ print(f" {topic}: {count}")
503
+
504
+ formats = {}
505
+ for task in tasks:
506
+ fmt = task.get("answer_format", "не определено")
507
+ formats[fmt] = formats.get(fmt, 0) + 1
508
+
509
+ print("\nФорматы ответов:")
510
+ for fmt, count in sorted(formats.items(), key=lambda x: -x[1]):
511
+ print(f" {fmt}: {count}")
512
+
513
+
514
+ if __name__ == "__main__":
515
+ main()
requirements.txt ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Web Scraping
2
+ requests>=2.31.0
3
+ beautifulsoup4>=4.12.0
4
+ lxml>=4.9.0
5
+ selenium>=4.15.0
6
+ webdriver-manager>=4.0.1
7
+
8
+ # AI Scraping
9
+ scrapegraphai>=1.0.0
10
+ langchain>=0.1.0
11
+ langchain-community>=0.0.10
12
+
13
+ # NLP
14
+ transformers>=4.35.0
15
+ torch>=2.0.0
16
+ spacy>=3.7.0
17
+ https://github.com/explosion/spacy-models/releases/download/ru_core_news_md-3.7.0/ru_core_news_md-3.7.0-py3-none-any.whl
18
+
19
+ # Data Processing
20
+ pydantic>=2.5.0
21
+ jsonlines>=4.0.0
22
+
23
+ # Supabase
24
+ python-dotenv>=1.0.0
25
+ supabase>=2.0.0
26
+ psycopg2-binary>=2.9.9
27
+
28
+ # API
29
+ fastapi>=0.100.0
30
+ uvicorn>=0.23.0
31
+
32
+ # Utilities
33
+ aiohttp>=3.9.0
34
+ asyncio>=3.4.3
supabase_client.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Расширенный клиент Supabase с поддержкой векторного поиска и embeddings
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import requests
8
+ import torch
9
+ from typing import List, Dict, Optional, Any
10
+ from datetime import datetime
11
+ from dotenv import load_dotenv
12
+
13
+ # Загружаем переменные окружения
14
+ load_dotenv()
15
+
16
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
17
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
18
+
19
+ SUPABASE_ENABLED = bool(SUPABASE_URL and SUPABASE_KEY)
20
+
21
+
22
+ class SupabaseEmbeddings:
23
+ """Генерация embeddings с помощью ruBERT"""
24
+
25
+ def __init__(self):
26
+ self.tokenizer = None
27
+ self.model = None
28
+ self._loaded = False
29
+
30
+ def load_model(self):
31
+ """Загрузка модели ruBERT"""
32
+ if self._loaded:
33
+ return
34
+
35
+ try:
36
+ from transformers import AutoTokenizer, AutoModel
37
+ print("Загрузка ruBERT для embeddings...")
38
+ self.tokenizer = AutoTokenizer.from_pretrained("DeepPavlov/rubert-base-cased")
39
+ self.model = AutoModel.from_pretrained("DeepPavlov/rubert-base-cased")
40
+ self.model.eval()
41
+ self._loaded = True
42
+ print("[OK] ruBERT загружен")
43
+ except Exception as e:
44
+ print(f"[WARN] ruBERT не загружен: {e}")
45
+
46
+ def get_embedding(self, text: str, max_length: int = 512) -> Optional[List[float]]:
47
+ """Получение векторного представления текста"""
48
+ if not self._loaded:
49
+ self.load_model()
50
+
51
+ if not self._loaded:
52
+ return None
53
+
54
+ try:
55
+ inputs = self.tokenizer(
56
+ text,
57
+ return_tensors="pt",
58
+ truncation=True,
59
+ max_length=max_length,
60
+ padding=True
61
+ )
62
+
63
+ with torch.no_grad():
64
+ outputs = self.model(**inputs)
65
+
66
+ # Mean pooling
67
+ token_embeddings = outputs.last_hidden_state
68
+ attention_mask = inputs["attention_mask"]
69
+ mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
70
+ embedding = torch.sum(token_embeddings * mask_expanded, 1) / torch.clamp(mask_expanded.sum(1), min=1e-9)
71
+
72
+ # Нормализация
73
+ embedding = torch.nn.functional.normalize(embedding, p=2, dim=1)
74
+
75
+ return embedding[0].tolist()
76
+
77
+ except Exception as e:
78
+ print(f"[ERROR] Ошибка генерации embeddings: {e}")
79
+ return None
80
+
81
+
82
+ class SupabaseClient:
83
+ """Расширенный клиент Supabase с векторным поиском"""
84
+
85
+ def __init__(self):
86
+ self.embeddings = SupabaseEmbeddings()
87
+ self.session = requests.Session()
88
+
89
+ if SUPABASE_ENABLED:
90
+ print(f"[OK] Supabase подключен: {SUPABASE_URL}")
91
+ else:
92
+ print("[WARN] Supabase не настроен")
93
+
94
+ # ============================================================
95
+ # CRUD ОПЕРАЦИИ
96
+ # ============================================================
97
+
98
+ def create_task(self, task_data: Dict) -> Optional[int]:
99
+ """Создание задания"""
100
+ if not SUPABASE_ENABLED:
101
+ return None
102
+
103
+ try:
104
+ url = f"{SUPABASE_URL}/rest/v1/tasks"
105
+ headers = self._get_headers()
106
+
107
+ # Генерируем embeddings для контента
108
+ content_text = f"{task_data.get('condition', '')} {task_data.get('content', '')}"
109
+ embedding = self.embeddings.get_embedding(content_text)
110
+
111
+ if embedding:
112
+ task_data['embeddings'] = json.dumps(embedding)
113
+
114
+ # Извлекаем ключевые слова
115
+ if 'keywords' not in task_data:
116
+ task_data['keywords'] = self._extract_keywords(content_text)
117
+
118
+ response = self.session.post(url, headers=headers, json=task_data, timeout=10)
119
+
120
+ if response.status_code in [200, 201]:
121
+ result = response.json()
122
+ if result:
123
+ return result[0].get("id")
124
+
125
+ print(f"[ERROR] Ошибка создания: {response.status_code}")
126
+ return None
127
+
128
+ except Exception as e:
129
+ print(f"[ERROR] Ошибка: {e}")
130
+ return None
131
+
132
+ def get_task(self, task_id: str) -> Optional[Dict]:
133
+ """Получение задания по ID"""
134
+ if not SUPABASE_ENABLED:
135
+ return None
136
+
137
+ try:
138
+ url = f"{SUPABASE_URL}/rest/v1/tasks?task_id=eq.{task_id}"
139
+ headers = self._get_headers()
140
+
141
+ response = self.session.get(url, headers=headers, timeout=10)
142
+
143
+ if response.status_code == 200:
144
+ tasks = response.json()
145
+ return tasks[0] if tasks else None
146
+
147
+ return None
148
+
149
+ except Exception as e:
150
+ print(f"[ERROR] Ошибка: {e}")
151
+ return None
152
+
153
+ def get_tasks(
154
+ self,
155
+ topic: Optional[str] = None,
156
+ limit: int = 100,
157
+ offset: int = 0
158
+ ) -> List[Dict]:
159
+ """Получение списка заданий с фильтрацией"""
160
+ if not SUPABASE_ENABLED:
161
+ return []
162
+
163
+ try:
164
+ url = f"{SUPABASE_URL}/rest/v1/tasks?limit={limit}&offset={offset}"
165
+ headers = self._get_headers()
166
+
167
+ if topic:
168
+ url += f"&topic=eq.{topic}"
169
+
170
+ response = self.session.get(url, headers=headers, timeout=10)
171
+
172
+ if response.status_code == 200:
173
+ return response.json()
174
+
175
+ return []
176
+
177
+ except Exception as e:
178
+ print(f"[ERROR] Ошибка: {e}")
179
+ return []
180
+
181
+ def update_task(self, task_id: str, updates: Dict) -> bool:
182
+ """Обновление задания"""
183
+ if not SUPABASE_ENABLED:
184
+ return False
185
+
186
+ try:
187
+ url = f"{SUPABASE_URL}/rest/v1/tasks?task_id=eq.{task_id}"
188
+ headers = self._get_headers()
189
+
190
+ response = self.session.patch(url, headers=headers, json=updates, timeout=10)
191
+
192
+ return response.status_code in [200, 204]
193
+
194
+ except Exception as e:
195
+ print(f"[ERROR] Ошибка: {e}")
196
+ return False
197
+
198
+ def delete_task(self, task_id: str) -> bool:
199
+ """Удаление задания"""
200
+ if not SUPABASE_ENABLED:
201
+ return False
202
+
203
+ try:
204
+ url = f"{SUPABASE_URL}/rest/v1/tasks?task_id=eq.{task_id}"
205
+ headers = self._get_headers()
206
+
207
+ response = self.session.delete(url, headers=headers, timeout=10)
208
+
209
+ return response.status_code in [200, 204]
210
+
211
+ except Exception as e:
212
+ print(f"[ERROR] Ошибка: {e}")
213
+ return False
214
+
215
+ # ============================================================
216
+ # ВЕКТОРНЫЙ ПОИСК
217
+ # ============================================================
218
+
219
+ def search_similar_tasks(
220
+ self,
221
+ query_text: str,
222
+ threshold: float = 0.7,
223
+ limit: int = 10
224
+ ) -> List[Dict]:
225
+ """Поиск похожих заданий с помощью векторного поиска"""
226
+ if not SUPABASE_ENABLED:
227
+ return []
228
+
229
+ # Генерируем embeddings для запроса
230
+ query_embedding = self.embeddings.get_embedding(query_text)
231
+
232
+ if not query_embedding:
233
+ # Fallback: текстовый поиск
234
+ return self._text_search(query_text, limit)
235
+
236
+ try:
237
+ # Используем RPC функцию для векторного поиска
238
+ url = f"{SUPABASE_URL}/rest/v1/rpc/find_similar_tasks"
239
+ headers = self._get_headers()
240
+
241
+ payload = {
242
+ "search_text": query_text,
243
+ "match_threshold": threshold,
244
+ "match_count": limit
245
+ }
246
+
247
+ response = self.session.post(url, headers=headers, json=payload, timeout=10)
248
+
249
+ if response.status_code == 200:
250
+ return response.json()
251
+
252
+ return []
253
+
254
+ except Exception as e:
255
+ print(f"[ERROR] Ошибка векторного поиска: {e}")
256
+ return self._text_search(query_text, limit)
257
+
258
+ def _text_search(self, query: str, limit: int = 10) -> List[Dict]:
259
+ """Текстовый поиск (fallback)"""
260
+ if not SUPABASE_ENABLED:
261
+ return []
262
+
263
+ try:
264
+ # Поиск по ключевым словам и теме
265
+ url = f"{SUPABASE_URL}/rest/v1/tasks?or=(topic.ilike.%{query}%,condition.ilike.%{query}%)&limit={limit}"
266
+ headers = self._get_headers()
267
+
268
+ response = self.session.get(url, headers=headers, timeout=10)
269
+
270
+ if response.status_code == 200:
271
+ return response.json()
272
+
273
+ return []
274
+
275
+ except Exception as e:
276
+ print(f"[ERROR] Ошибка текстового поиска: {e}")
277
+ return []
278
+
279
+ # ============================================================
280
+ # МАССОВЫЕ ОПЕРАЦИИ
281
+ # ============================================================
282
+
283
+ def save_tasks_batch(self, tasks: List[Dict]) -> Dict:
284
+ """Массовое сохранение заданий"""
285
+ if not SUPABASE_ENABLED:
286
+ return {"saved": 0, "failed": 0, "total": len(tasks), "error": "Supabase не подключен"}
287
+
288
+ stats = {"saved": 0, "failed": 0, "total": len(tasks)}
289
+
290
+ print(f"\nСохранение {len(tasks)} заданий в Supabase...")
291
+
292
+ for i, task in enumerate(tasks, 1):
293
+ print(f" [{i}/{len(tasks)}]")
294
+ result = self.create_task(task)
295
+ if result:
296
+ stats["saved"] += 1
297
+ else:
298
+ stats["failed"] += 1
299
+
300
+ print(f"\n[OK] Сохранено: {stats['saved']}, Ошибок: {stats['failed']}")
301
+
302
+ return stats
303
+
304
+ # ============================================================
305
+ # АНАЛИТИКА
306
+ # ============================================================
307
+
308
+ def get_topic_stats(self) -> List[Dict]:
309
+ """Статистика по темам"""
310
+ if not SUPABASE_ENABLED:
311
+ return []
312
+
313
+ try:
314
+ url = f"{SUPABASE_URL}/rest/v1/rpc/get_topic_stats"
315
+ headers = self._get_headers()
316
+
317
+ response = self.session.post(url, headers=headers, json={}, timeout=10)
318
+
319
+ if response.status_code == 200:
320
+ return response.json()
321
+
322
+ return []
323
+
324
+ except Exception as e:
325
+ print(f"[ERROR] Ошибка статистики: {e}")
326
+ return []
327
+
328
+ def get_random_tasks(self, topic: Optional[str] = None, limit: int = 10) -> List[Dict]:
329
+ """Получение случайных заданий"""
330
+ if not SUPABASE_ENABLED:
331
+ return []
332
+
333
+ try:
334
+ url = f"{SUPABASE_URL}/rest/v1/rpc/get_random_tasks"
335
+ headers = self._get_headers()
336
+
337
+ payload = {"limit_count": limit}
338
+ if topic:
339
+ payload["topic_filter"] = topic
340
+
341
+ response = self.session.post(url, headers=headers, json=payload, timeout=10)
342
+
343
+ if response.status_code == 200:
344
+ return response.json()
345
+
346
+ return []
347
+
348
+ except Exception as e:
349
+ print(f"[ERROR] Ошибка: {e}")
350
+ return []
351
+
352
+ # ============================================================
353
+ # УТИЛИТЫ
354
+ # ============================================================
355
+
356
+ def _get_headers(self) -> Dict:
357
+ """Получение заголовков для API запросов"""
358
+ return {
359
+ "apikey": SUPABASE_KEY,
360
+ "Authorization": f"Bearer {SUPABASE_KEY}",
361
+ "Content-Type": "application/json",
362
+ "Prefer": "return=representation"
363
+ }
364
+
365
+ def _extract_keywords(self, text: str, max_keywords: int = 10) -> List[str]:
366
+ """Извлечение ключевых слов (простая реализация)"""
367
+ # Стоп-слова для русского языка
368
+ stop_words = {
369
+ 'и', 'в', 'во', 'не', 'что', 'он', 'на', 'я', 'с', 'со', 'как', 'а', 'то',
370
+ 'все', 'она', 'так', 'его', 'но', 'да', 'ты', 'к', 'у', 'же', 'вы', 'за',
371
+ 'бы', 'по', 'только', 'ее', 'мне', 'было', 'вот', 'от', 'меня', 'еще',
372
+ 'нет', 'о', 'из', 'ему', 'теперь', 'когда', 'даже', 'ну', 'вдруг', 'ли',
373
+ 'если', 'уже', 'или', 'ни', 'быть', 'был', 'него', 'до', 'вас', 'нибудь',
374
+ 'опять', 'уж', 'вам', 'вед', 'пусть', 'тогда', 'кто', 'этой', 'того',
375
+ 'потому', 'этот', 'какой', 'совсем', 'ним', 'здесь', 'этом', 'один',
376
+ 'почти', 'мой', 'тем', 'чтобы', 'нее', 'сейчас', 'были', 'куда', 'зачем',
377
+ 'всех', 'никогда', 'можно', 'при', 'наконец', 'два', 'об', 'другой',
378
+ 'хоть', 'после', 'над', 'больше', 'тот', 'через', 'эти', 'нас', 'про',
379
+ 'всего', 'них', 'какая', 'много', 'разве', 'три', 'эту', 'моя', 'впрочем',
380
+ 'хорошо', 'у', 'для', 'че', 'лет', 'который', 'правда', 'место', 'слово'
381
+ }
382
+
383
+ words = text.lower().split()
384
+ keywords = []
385
+
386
+ for word in words:
387
+ # Очищаем от знаков препинания
388
+ word = ''.join(c for c in word if c.isalpha())
389
+
390
+ if len(word) > 3 and word not in stop_words and word not in keywords:
391
+ keywords.append(word)
392
+
393
+ if len(keywords) >= max_keywords:
394
+ break
395
+
396
+ return keywords
397
+
398
+ def test_connection(self) -> bool:
399
+ """Проверка подключения"""
400
+ if not SUPABASE_ENABLED:
401
+ return False
402
+
403
+ try:
404
+ url = f"{SUPABASE_URL}/rest/v1/tasks?limit=1"
405
+ headers = self._get_headers()
406
+
407
+ response = self.session.get(url, headers=headers, timeout=10)
408
+
409
+ return response.status_code == 200
410
+
411
+ except Exception as e:
412
+ print(f"[ERROR] Ошибка подключения: {e}")
413
+ return False
414
+
415
+
416
+ # ============================================================
417
+ # ДЕКОРАТОР ДЛЯ АСИНХРОННОЙ ОЧЕРЕДИ
418
+ # ============================================================
419
+
420
+ class EmbeddingsQueue:
421
+ """Очередь для асинхронной генерации embeddings"""
422
+
423
+ def __init__(self, supabase_client: SupabaseClient):
424
+ self.client = supabase_client
425
+
426
+ def enqueue(self, task_id: str, text: str) -> bool:
427
+ """Добавление задачи в очередь"""
428
+ if not SUPABASE_ENABLED:
429
+ return False
430
+
431
+ try:
432
+ url = f"{SUPABASE_URL}/rest/v1/rpc/pgmq_send"
433
+ headers = self.client._get_headers()
434
+
435
+ payload = {
436
+ "queue_name": "embeddings_queue",
437
+ "message": {
438
+ "task_id": task_id,
439
+ "text": text,
440
+ "created_at": datetime.now().isoformat()
441
+ }
442
+ }
443
+
444
+ response = self.client.session.post(url, headers=headers, json=payload, timeout=10)
445
+
446
+ return response.status_code in [200, 201]
447
+
448
+ except Exception as e:
449
+ print(f"[ERROR] Ошибка очереди: {e}")
450
+ return False
451
+
452
+
453
+ # ============================================================
454
+ # ЗАПУСК
455
+ # ============================================================
456
+
457
+ if __name__ == "__main__":
458
+ print("="*60)
459
+ print("Тестирование Supabase клиента")
460
+ print("="*60)
461
+
462
+ client = SupabaseClient()
463
+
464
+ if client.test_connection():
465
+ print("\n[OK] Подключение к Supabase успешно!")
466
+
467
+ # Тест получения заданий
468
+ tasks = client.get_tasks(limit=5)
469
+ print(f"\nПолучено заданий: {len(tasks)}")
470
+
471
+ # Тест статистики
472
+ stats = client.get_topic_stats()
473
+ print(f"\nСтатистика по темам: {stats}")
474
+
475
+ # Тест векторного поиска
476
+ similar = client.search_similar_tasks("орфография корни слов", limit=3)
477
+ print(f"\nПохожие задания: {len(similar)}")
478
+
479
+ else:
480
+ print("\n[WARN] Supabase не подключен")
481
+ print("Настройте переменные окружения:")
482
+ print(" SUPABASE_URL=https://your-project.supabase.co")
483
+ print(" SUPABASE_KEY=your-anon-key")