UnMelow commited on
Commit
b8bc019
·
verified ·
1 Parent(s): 9ebbb52

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +523 -517
app.py CHANGED
@@ -1,550 +1,556 @@
 
 
 
1
  import gradio as gr
 
2
  import torch
3
- from transformers import (
4
- pipeline,
5
- AutoTokenizer,
6
- AutoModelForSeq2SeqLM,
7
- BitsAndBytesConfig
8
- )
9
  from sentence_transformers import SentenceTransformer
10
- import numpy as np
11
- from typing import List, Dict
12
- import PyPDF2
13
- import io
14
- import re
15
- import os
16
 
17
- # ==================== КОНФИГУРАЦИЯ ====================
18
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
19
- MAX_CHUNK_SIZE = 512
20
- CACHE_DIR = "./models"
21
-
22
- # Создаем директорию для кэша
23
- os.makedirs(CACHE_DIR, exist_ok=True)
24
-
25
- # ==================== ЗАГРУЗКА МОДЕЛЕЙ ====================
26
- def load_models():
27
- """Загрузка моделей с оптимизацией под HuggingFace Space"""
28
- models = {}
29
-
30
- print("Загрузка энкодера для поиска...")
31
- # Более легкая модель для эмбеддингов
32
- models["encoder"] = SentenceTransformer(
33
- "sentence-transformers/all-MiniLM-L6-v2",
34
- device="cpu"
35
- )
36
-
37
- print("Загрузка суммаризатора...")
38
- # Используем более надежную модель для суммаризации
39
- models["summarizer"] = {
40
- "model_name": "sshleifer/distilbart-cnn-12-6",
41
- "pipeline": None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
43
-
44
- print("Загрузка чат-модели...")
45
- # Используем T5 для генерации
46
- models["chat_tokenizer"] = AutoTokenizer.from_pretrained(
47
- "google/flan-t5-base",
48
- cache_dir=CACHE_DIR
49
- )
50
-
51
- models["chat_model"] = AutoModelForSeq2SeqLM.from_pretrained(
52
- "google/flan-t5-base",
53
- cache_dir=CACHE_DIR,
54
- torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
55
- device_map="auto" if DEVICE == "cuda" else None,
56
- low_cpu_mem_usage=True
57
- )
58
-
59
- return models
60
 
61
- # ==================== ОБРАБОТКА ТЕКСТА ====================
62
- def extract_text_from_pdf(file) -> str:
63
- """Извлечение текста из PDF"""
64
- text = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  try:
66
- if hasattr(file, 'read'):
67
- pdf_reader = PyPDF2.PdfReader(io.BytesIO(file.read()))
68
- else:
69
- pdf_reader = PyPDF2.PdfReader(file)
70
-
71
- for page in pdf_reader.pages:
72
- page_text = page.extract_text()
73
- if page_text:
74
- text += page_text + "\n"
75
  except Exception as e:
76
- print(f"Ошибка чтения PDF: {e}")
 
 
 
 
 
 
 
 
 
 
 
77
  return ""
78
-
79
- return text.strip()
80
-
81
- def clean_text(text: str) -> str:
82
- """Очистка текста"""
83
- text = re.sub(r'\s+', ' ', text)
84
- text = re.sub(r'[^\w\s.,!?;:()-]', '', text)
85
- return text.strip()
86
-
87
- def chunk_text(text: str, chunk_size: int = MAX_CHUNK_SIZE) -> List[str]:
88
- """Разбиение текста на чанки"""
89
- sentences = re.split(r'(?<=[.!?])\s+', text)
90
-
91
- chunks = []
92
- current_chunk = []
93
- current_length = 0
94
-
95
- for sentence in sentences:
96
- sentence_length = len(sentence)
97
- if current_length + sentence_length > chunk_size and current_chunk:
98
- chunks.append(' '.join(current_chunk))
99
- current_chunk = [sentence]
100
- current_length = sentence_length
101
- else:
102
- current_chunk.append(sentence)
103
- current_length += sentence_length
104
-
105
- if current_chunk:
106
- chunks.append(' '.join(current_chunk))
107
-
108
- return chunks
109
 
110
- # ==================== ПОИСК РЕЛЕВАНТНЫХ ЧАНКОВ ====================
111
- class SimpleSearch:
112
- """Упрощенная система поиска"""
113
- def __init__(self, encoder_model):
114
- self.encoder = encoder_model
115
- self.chunks = []
116
- self.embeddings = None
117
-
118
- def build_index(self, chunks: List[str]):
119
- """Создание эмбеддингов"""
120
- self.chunks = chunks[:100] # Ограничиваем количество
121
- if self.chunks:
122
- self.embeddings = self.encoder.encode(
123
- self.chunks,
124
- convert_to_numpy=True,
125
- show_progress_bar=False
126
- )
127
-
128
- def search(self, query: str, k: int = 3) -> List[str]:
129
- """Поиск похожих чанков"""
130
- if not self.embeddings or len(self.embeddings) == 0:
131
- return self.chunks[:k] if self.chunks else []
132
-
133
  try:
134
- query_embedding = self.encoder.encode([query], convert_to_numpy=True)
135
- similarities = np.dot(self.embeddings, query_embedding.T).flatten()
136
- top_indices = np.argsort(similarities)[-k:][::-1]
137
-
138
- results = []
139
- for idx in top_indices:
140
- if similarities[idx] > 0.3:
141
- results.append(self.chunks[idx])
142
-
143
- return results if results else self.chunks[:k]
144
- except:
145
- return self.chunks[:k]
146
-
147
- # ==================== ФУНКЦИИ ГЕНЕРАЦИИ ====================
148
- def get_summarizer(models_dict: Dict):
149
- """Ленивая загрузка суммаризатора"""
150
- if models_dict["summarizer"]["pipeline"] is None:
151
- print("Загрузка суммаризатора...")
152
- models_dict["summarizer"]["pipeline"] = pipeline(
153
- "summarization",
154
- model=models_dict["summarizer"]["model_name"],
155
- tokenizer=models_dict["summarizer"]["model_name"],
156
- device=0 if DEVICE == "cuda" else -1,
157
- model_kwargs={"cache_dir": CACHE_DIR}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  )
159
- return models_dict["summarizer"]["pipeline"]
160
-
161
- def generate_summary(text: str, models_dict: Dict) -> Dict:
162
- """Генерация конспекта"""
163
- summarizer = get_summarizer(models_dict)
164
-
165
- # Ограничиваем длину текста
166
- text = text[:3000]
167
-
168
- try:
169
- summary = summarizer(
170
- text,
171
- max_length=200,
172
- min_length=100,
173
- do_sample=False,
174
- truncation=True
175
- )[0]['summary_text']
176
-
177
- sentences = summary.split('. ')
178
- short_summary = '. '.join(sentences[:2]) + '.' if len(sentences) > 1 else summary
179
- detailed_summary = summary
180
-
181
- return {
182
- "short": short_summary,
183
- "detailed": detailed_summary
184
- }
185
- except Exception as e:
186
- print(f"Ошибка суммаризации: {e}")
187
- return {
188
- "short": text[:200] + "...",
189
- "detailed": text[:500] + "..."
190
- }
191
-
192
- def explain_simple(text: str, level: str, models_dict: Dict) -> str:
193
- """Объяснение простым языком"""
194
- prompt_templates = {
195
- "school": "Объясни этот текст простыми словами, чтобы понял школьник:\n\n{text}\n\nОбъяснение:",
196
- "student": "Объясни этот текст простым языком для студента:\n\n{text}\n\nОбъяснение:",
197
- "expert": "Сделай профессиональное разъяснение этого текста:\n\n{text}\n\nРазъяснение:"
198
- }
199
-
200
- prompt = prompt_templates[level].format(text=text[:800])
201
-
202
- inputs = models_dict["chat_tokenizer"](
203
- prompt,
204
- return_tensors="pt",
205
- max_length=512,
206
- truncation=True,
207
- padding=True
208
- )
209
-
210
- if DEVICE == "cuda":
211
- inputs = inputs.to("cuda")
212
-
213
- outputs = models_dict["chat_model"].generate(
214
- **inputs,
215
- max_new_tokens=300,
216
- temperature=0.7,
217
- do_sample=True,
218
- repetition_penalty=1.2
219
- )
220
-
221
- explanation = models_dict["chat_tokenizer"].decode(
222
- outputs[0],
223
- skip_special_tokens=True
224
  )
225
-
226
- return explanation
227
-
228
- def generate_questions(text: str, difficulty: str, models_dict: Dict) -> str:
229
- """Генерация тестовых вопросов"""
230
- prompt = f"""Сгенерируй 3 тестовых вопроса по тексту.
231
- Уровень сложности: {difficulty}
232
-
233
- Текст: {text[:1000]}
234
-
235
- Формат вывода:
236
- 1. [Вопрос с вариантами ответов]
237
- a) Вариант 1
238
- b) Вариант 2
239
- c) Вариант 3
240
- d) Правильный вариант: [буква]
241
-
242
- 2. [Открытый вопрос]
243
- Ответ: [краткий ответ]
244
-
245
- 3. [Вопрос на понимание]
246
- Ответ: [объяснение]"""
247
-
248
- inputs = models_dict["chat_tokenizer"](
249
  prompt,
250
- return_tensors="pt",
251
- max_length=1024,
252
  truncation=True,
253
- padding=True
254
- )
255
-
256
- if DEVICE == "cuda":
257
- inputs = inputs.to("cuda")
258
-
259
- outputs = models_dict["chat_model"].generate(
260
- **inputs,
261
- max_new_tokens=500,
262
- temperature=0.8,
263
- do_sample=True,
264
- repetition_penalty=1.1
265
- )
266
-
267
- questions = models_dict["chat_tokenizer"].decode(
268
- outputs[0],
269
- skip_special_tokens=True
270
- )
271
-
272
- return questions
273
 
274
- def chat_with_document(query: str, search_system: SimpleSearch, models_dict: Dict) -> str:
275
- """Чат с документом"""
276
- if not search_system.chunks:
 
 
 
 
 
277
  return "Сначала загрузите документ."
278
-
279
- relevant_chunks = search_system.search(query, k=3)
280
-
281
- if not relevant_chunks:
282
- return "Не удалось найти информацию по вашему вопросу в документе."
283
-
284
- context = "\n".join(relevant_chunks[:2])
285
-
286
- prompt = f"""Ответь на вопрос на основе контекста из документа.
287
 
288
- Контекст: {context}
289
 
290
- Вопрос: {query}
 
 
 
 
291
 
292
- Ответь четко и по делу. Если в контексте нет информации, скажи об этом.
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
- Ответ:"""
295
-
296
- inputs = models_dict["chat_tokenizer"](
297
  prompt,
298
- return_tensors="pt",
299
- max_length=1024,
300
  truncation=True,
301
- padding=True
302
- )
303
-
304
- if DEVICE == "cuda":
305
- inputs = inputs.to("cuda")
306
-
307
- outputs = models_dict["chat_model"].generate(
308
- **inputs,
309
- max_new_tokens=400,
310
- temperature=0.7,
311
- do_sample=True,
312
- repetition_penalty=1.2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  )
314
-
315
- answer = models_dict["chat_tokenizer"].decode(
316
- outputs[0],
317
- skip_special_tokens=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  )
319
-
320
- return answer
321
-
322
- # ==================== GRADIO ИНТЕРФЕЙС ====================
323
- def create_interface():
324
- # Загружаем модели
325
- models = load_models()
326
- search_system = SimpleSearch(models["encoder"])
327
-
328
- # Состояние приложения
329
- current_state = {
330
- "text": "",
331
- "processed": False
332
- }
333
-
334
- def process_document(file, text_input):
335
- """Обработка документа"""
336
- text = ""
337
-
338
- if file is not None:
339
- text = extract_text_from_pdf(file)
340
- elif text_input:
341
- text = text_input
342
-
343
- if not text:
344
- return "❌ Пожалуйста, загрузите файл или введите текст", "", "", ""
345
-
346
- text = clean_text(text)
347
- current_state["text"] = text
348
-
349
- chunks = chunk_text(text)
350
- search_system.build_index(chunks)
351
-
352
- summary = generate_summary(text[:2000], models)
353
-
354
- word_count = len(text.split())
355
- status = f"✅ Документ обработан ({word_count} слов)"
356
-
357
- preview = text[:500] + "..." if len(text) > 500 else text
358
-
359
- current_state["processed"] = True
360
-
361
- return status, summary["short"], summary["detailed"], preview
362
-
363
- def handle_explain(level):
364
- """Обработка объяснения"""
365
- if not current_state["processed"]:
366
- return "Сначала загрузите и обработайте документ."
367
-
368
- return explain_simple(current_state["text"][:1000], level, models)
369
-
370
- def handle_questions(difficulty):
371
- """Генерация вопросов"""
372
- if not current_state["processed"]:
373
- return "Сначала загрузите и обработайте документ."
374
-
375
- return generate_questions(current_state["text"][:1500], difficulty, models)
376
-
377
- def handle_chat(message, history):
378
- """Обработчик чата"""
379
- if not current_state["processed"]:
380
- return "Сначала загрузите и обработайте документ."
381
-
382
- response = chat_with_document(message, search_system, models)
383
- return response
384
-
385
- # Создание интерфейса
386
- with gr.Blocks(title="EduMultiSpace", theme=gr.themes.Soft()) as app:
387
- gr.Markdown("""
388
- # 📚 EduMultiSpace: Умный помощник по учебным материалам
389
-
390
- *Загрузите учебный материал (PDF или текст) и получите:*
391
- - 📝 Автоматический конспект
392
- - 🎓 Объяснение простым языком
393
- - ❓ Тестовые вопросы для проверки
394
- - 💬 Чат с документом
395
- """)
396
-
397
- # Вкладки
398
- with gr.Tabs():
399
- # Вкладка 1: Загрузка
400
- with gr.Tab("📄 Загрузить документ"):
401
- with gr.Row():
402
- with gr.Column(scale=1):
403
- gr.Markdown("### Загрузите учебный материал")
404
- file_input = gr.File(
405
- label="PDF файл",
406
- file_types=[".pdf", ".txt"]
407
- )
408
- text_input = gr.Textbox(
409
- label="Или вставьте текст",
410
- lines=8,
411
- placeholder="Вставьте текст лекции, статьи, учебника..."
412
- )
413
- process_btn = gr.Button(
414
- "📊 Обработать документ",
415
- variant="primary",
416
- size="lg"
417
- )
418
-
419
- with gr.Column(scale=2):
420
- status = gr.Markdown("**Статус:** Ожид��ние документа")
421
-
422
- with gr.Accordion("Превью текста", open=False):
423
- preview_text = gr.Markdown()
424
-
425
- with gr.Row():
426
- with gr.Column():
427
- gr.Markdown("### 🎯 Краткий конспект")
428
- short_summary = gr.Textbox(
429
- lines=4,
430
- label="",
431
- interactive=False
432
- )
433
- with gr.Column():
434
- gr.Markdown("### 📖 Подробный конспект")
435
- detailed_summary = gr.Textbox(
436
- lines=6,
437
- label="",
438
- interactive=False
439
- )
440
-
441
- # Вкладка 2: Объяснение
442
- with gr.Tab("🎓 Объяснить просто"):
443
- gr.Markdown("### Объяснение материала разным уровнем сложности")
444
- level = gr.Radio(
445
- choices=["school", "student", "expert"],
446
- label="Уровень объяснения",
447
- value="student",
448
- info="Выберите, для кого объяснять"
449
- )
450
- explain_btn = gr.Button("🤔 Объяснить текст", variant="primary")
451
- explanation_output = gr.Textbox(
452
- label="Результат",
453
- lines=8,
454
- interactive=False
455
- )
456
-
457
- # Вкладка 3: Вопросы
458
- with gr.Tab("❓ Тесты и вопросы"):
459
- gr.Markdown("### Сгенерируйте вопросы для самопроверки")
460
- difficulty = gr.Radio(
461
- choices=["легкий", "средний", "сложный"],
462
- label="Сложность вопросов",
463
- value="средний"
464
- )
465
- questions_btn = gr.Button("📝 Создать вопросы", variant="primary")
466
- questions_output = gr.Textbox(
467
- label="Вопросы для проверки знаний",
468
- lines=12,
469
- interactive=False
470
- )
471
-
472
- # Вкладка 4: Чат
473
- with gr.Tab("💬 Чат с документом"):
474
- gr.Markdown("### Задавайте вопросы по содержанию документа")
475
-
476
- chatbot = gr.Chatbot(
477
- label="Диалог",
478
- height=400
479
- )
480
-
481
- msg = gr.Textbox(
482
- label="Ваш вопрос",
483
- placeholder="Задайте вопрос о документе...",
484
- scale=4
485
- )
486
-
487
- examples = gr.Examples(
488
- examples=[
489
- "В чем основная идея?",
490
- "Объясни ключевые термины",
491
- "Какие выводы можно сделать?",
492
- "Кратко перескажи содержание"
493
- ],
494
- inputs=msg,
495
- label="Примеры вопросов"
496
- )
497
-
498
- with gr.Row():
499
- clear_btn = gr.Button("Очистить чат")
500
- submit_btn = gr.Button("Отправить", variant="primary")
501
-
502
- # Обработчики событий
503
- process_btn.click(
504
- process_document,
505
- inputs=[file_input, text_input],
506
- outputs=[status, short_summary, detailed_summary, preview_text]
507
  )
508
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  explain_btn.click(
510
- handle_explain,
511
- inputs=[level],
512
- outputs=[explanation_output]
513
  )
514
-
515
- questions_btn.click(
516
- handle_questions,
517
- inputs=[difficulty],
518
- outputs=[questions_output]
 
 
519
  )
520
-
521
- def respond(message, chat_history):
522
- response = handle_chat(message, chat_history)
523
- chat_history.append((message, response))
524
- return "", chat_history
525
-
526
- submit_btn.click(
527
- respond,
528
- inputs=[msg, chatbot],
529
- outputs=[msg, chatbot]
530
  )
531
-
532
- msg.submit(
533
- respond,
534
- inputs=[msg, chatbot],
535
- outputs=[msg, chatbot]
536
  )
537
-
538
- clear_btn.click(lambda: None, None, chatbot, queue=False)
539
-
540
- return app
541
 
542
- # ==================== ЗАПУСК ====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  if __name__ == "__main__":
544
- app = create_interface()
545
- app.launch(
546
- server_name="0.0.0.0",
547
- server_port=7860,
548
- share=False,
549
- debug=False
550
- )
 
1
+ import os
2
+ from typing import List, Dict, Any, Tuple
3
+
4
  import gradio as gr
5
+ import numpy as np
6
  import torch
7
+ from transformers import pipeline
 
 
 
 
 
8
  from sentence_transformers import SentenceTransformer
 
 
 
 
 
 
9
 
10
+
11
+ # ========= КОНСТАНТЫ И ОГРАНИЧЕНИЯ ==========
12
+
13
+ # Общий лимит для хранимого текста (для чата/поиска) — можно увеличить,
14
+ # но без фанатизма, чтобы не упираться в память.
15
+ MAX_DOC_CHARS = 200_000
16
+
17
+ # Лимит для текста, который отправляем на суммаризацию (самый дорогой шаг)
18
+ MAX_SUMM_CHARS = 30_000
19
+
20
+ # Размер чанка для индекса/суммаризации
21
+ CHUNK_SIZE = 700
22
+ CHUNK_OVERLAP = 150
23
+
24
+ # Модели (все публичные и относительно лёгкие)
25
+ EMB_MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # энкодер
26
+ SUMM_MODEL_NAME = "d0rj/rut5-base-summ" # суммаризация (RU T5)
27
+ CHAT_MODEL_NAME = "google/flan-t5-small" # чат/инструкции
28
+
29
+ DEVICE = 0 if torch.cuda.is_available() else -1
30
+
31
+ # Ограничим потоки
32
+ torch.set_num_threads(4)
33
+
34
+
35
+ # ========= ЗАГРУЗКА МОДЕЛЕЙ ==========
36
+
37
+ print("Загружаем модели...")
38
+
39
+ emb_model = SentenceTransformer(EMB_MODEL_NAME)
40
+ if torch.cuda.is_available():
41
+ emb_model = emb_model.to("cuda")
42
+
43
+ summarizer = pipeline(
44
+ "summarization",
45
+ model=SUMM_MODEL_NAME,
46
+ device=DEVICE,
47
+ )
48
+
49
+ chat_model = pipeline(
50
+ "text2text-generation",
51
+ model=CHAT_MODEL_NAME,
52
+ device=DEVICE,
53
+ )
54
+
55
+ print("Модели загружены.")
56
+
57
+
58
+ # ========= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
59
+
60
+ def normalize_whitespace(text: str) -> str:
61
+ """Убираем лишние пробелы и пустые строки."""
62
+ lines = [line.strip() for line in text.splitlines()]
63
+ cleaned = "\n".join(line for line in lines if line)
64
+ return cleaned
65
+
66
+
67
+ def split_into_chunks(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
68
+ """
69
+ Делим текст на куски по символам с перекрытием.
70
+ Это дешево по памяти и позволяет обрабатывать длинные тексты по частям.
71
+ """
72
+ text = text.strip()
73
+ chunks: List[str] = []
74
+ start = 0
75
+ n = len(text)
76
+ while start < n:
77
+ end = min(start + chunk_size, n)
78
+ chunk = text[start:end].strip()
79
+ if chunk:
80
+ chunks.append(chunk)
81
+ start = end - overlap
82
+ if start < 0:
83
+ start = 0
84
+ if start >= n:
85
+ break
86
+ return chunks
87
+
88
+
89
+ def build_index(text: str) -> Dict[str, Any]:
90
+ """
91
+ Строим векторный индекс для чанков текста.
92
+ Эмбеддинги храним в float16 для экономии памяти.
93
+ """
94
+ chunks = split_into_chunks(text)
95
+ if not chunks:
96
+ return {"text": text, "chunks": [], "embeddings": None}
97
+
98
+ embeddings = emb_model.encode(
99
+ chunks,
100
+ convert_to_numpy=True,
101
+ show_progress_bar=False,
102
+ batch_size=32,
103
+ ).astype(np.float16)
104
+
105
+ return {
106
+ "text": text,
107
+ "chunks": chunks,
108
+ "embeddings": embeddings,
109
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+
112
+ def retrieve_context(query: str, state: Dict[str, Any], top_k: int = 4) -> List[str]:
113
+ """
114
+ Находим top_k самых похожих чанков под запрос пользователя.
115
+ """
116
+ if not state or state.get("embeddings") is None:
117
+ return []
118
+
119
+ embeddings = state["embeddings"]
120
+ chunks = state["chunks"]
121
+ if embeddings is None or len(chunks) == 0:
122
+ return []
123
+
124
+ query_emb = emb_model.encode([query], convert_to_numpy=True)[0].astype(np.float16)
125
+
126
+ # косинусное сходство
127
+ emb_f = embeddings.astype(np.float32)
128
+ query_f = query_emb.astype(np.float32)
129
+
130
+ doc_norms = np.linalg.norm(emb_f, axis=1) + 1e-8
131
+ query_norm = np.linalg.norm(query_f) + 1e-8
132
+ sims = (emb_f @ query_f) / (doc_norms * query_norm)
133
+
134
+ top_idx = np.argsort(sims)[-top_k:][::-1]
135
+ result_chunks = []
136
+ for i in top_idx:
137
+ i = int(i)
138
+ if 0 <= i < len(chunks):
139
+ result_chunks.append(chunks[i])
140
+ return result_chunks
141
+
142
+
143
+ def summarize_document(text: str) -> Tuple[str, str]:
144
+ """
145
+ Двухуровневая суммаризация:
146
+ - короткий конспект (более общий)
147
+ - длинный конспект (по частям, детальнее)
148
+ """
149
+ text = text.strip()
150
+ if not text:
151
+ return "", ""
152
+
153
+ text_for_summ = text[:MAX_SUMM_CHARS]
154
+
155
+ # Небольшой текст — одним заходом
156
+ if len(text_for_summ) <= CHUNK_SIZE:
157
+ result = summarizer(
158
+ text_for_summ,
159
+ max_length=220,
160
+ min_length=60,
161
+ do_sample=False,
162
+ truncation=True,
163
+ )[0]["summary_text"]
164
+ # для малых текстов делаем оба конспекта одинаковыми
165
+ return result, result
166
+
167
+ # Длинный текст — разбиваем на чанки и суммируем каждый
168
+ chunks = split_into_chunks(text_for_summ, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP)
169
+ chunk_summaries: List[str] = []
170
+
171
+ for ch in chunks:
172
+ try:
173
+ s = summarizer(
174
+ ch,
175
+ max_length=160,
176
+ min_length=50,
177
+ do_sample=False,
178
+ truncation=True,
179
+ )[0]["summary_text"]
180
+ except Exception as e:
181
+ print("Ошибка суммаризации чанка:", e)
182
+ s = ch[:400]
183
+ chunk_summaries.append(s)
184
+
185
+ # Длинный конспект — конкатенация суммаризаций
186
+ long_summary = "\n\n".join(chunk_summaries)
187
+ # Чуть подрежем, чтобы не раздувать UI
188
+ long_summary = long_summary[:5000]
189
+
190
+ # Краткий конспект — дополнительная суммаризация первых N кусочков
191
+ short_source = " ".join(chunk_summaries[: max(1, len(chunk_summaries) // 2)])
192
+ short_source = short_source[:2500]
193
+
194
  try:
195
+ short_summary = summarizer(
196
+ short_source,
197
+ max_length=220,
198
+ min_length=80,
199
+ do_sample=False,
200
+ truncation=True,
201
+ )[0]["summary_text"]
 
 
202
  except Exception as e:
203
+ print("Ошибка итоговой суммаризации:", e)
204
+ short_summary = short_source
205
+
206
+ return short_summary, long_summary
207
+
208
+
209
+ def extract_text_from_file(file_obj) -> str:
210
+ """
211
+ Чтение текста из .txt или .pdf файла.
212
+ Для PDF читаем постранично и обрезаем по MAX_DOC_CHARS.
213
+ """
214
+ if file_obj is None:
215
  return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
+ name = getattr(file_obj, "name", "")
218
+ ext = os.path.splitext(name)[1].lower()
219
+
220
+ # .txt
221
+ if ext == ".txt":
222
+ content = file_obj.read()
223
+ if isinstance(content, bytes):
224
+ content = content.decode("utf-8", errors="ignore")
225
+ content = normalize_whitespace(content)
226
+ if len(content) > MAX_DOC_CHARS:
227
+ content = content[:MAX_DOC_CHARS]
228
+ return content
229
+
230
+ # .pdf
231
+ if ext == ".pdf":
 
 
 
 
 
 
 
 
232
  try:
233
+ import pypdf
234
+ except ImportError:
235
+ return "Ошибка: для PDF нужен пакет 'pypdf' (добавьте его в requirements.txt)."
236
+
237
+ reader = pypdf.PdfReader(file_obj)
238
+ pages_text = []
239
+ total_len = 0
240
+
241
+ for page in reader.pages:
242
+ t = page.extract_text() or ""
243
+ t = t.strip()
244
+ if not t:
245
+ continue
246
+
247
+ # добавляем постранично, пока не достигли лимита
248
+ to_add = "\n" + t
249
+ if total_len + len(to_add) > MAX_DOC_CHARS:
250
+ remaining = MAX_DOC_CHARS - total_len
251
+ if remaining > 0:
252
+ pages_text.append(to_add[:remaining])
253
+ total_len += remaining
254
+ break
255
+
256
+ pages_text.append(to_add)
257
+ total_len += len(to_add)
258
+
259
+ if total_len >= MAX_DOC_CHARS:
260
+ break
261
+
262
+ content = normalize_whitespace("".join(pages_text))
263
+ return content
264
+
265
+ # неизвестный формат — просто ничего
266
+ return ""
267
+
268
+
269
+ # ========= ЛОГИКА ДЛЯ UI ==========
270
+
271
+ def load_document(file, raw_text, prev_state):
272
+ """
273
+ Загрузка документа: файл или текст.
274
+ - Чистим и (при необходимости) обрезаем текст.
275
+ - Строим индекс.
276
+ - Делаем 2 уровня суммаризации.
277
+ """
278
+ if file is not None:
279
+ text = extract_text_from_file(file)
280
+ else:
281
+ text = raw_text or ""
282
+
283
+ if not isinstance(text, str) or not text.strip():
284
+ return (
285
+ "",
286
+ "",
287
+ prev_state,
288
+ "❗ Пожалуйста, загрузите файл (.txt/.pdf) или вставьте текст.",
289
  )
290
+
291
+ text = normalize_whitespace(text)
292
+ truncated = len(text) > MAX_DOC_CHARS
293
+ if truncated:
294
+ text = text[:MAX_DOC_CHARS]
295
+
296
+ state = build_index(text)
297
+ short_summary, long_summary = summarize_document(text)
298
+
299
+ status_msg = f"✅ Документ загружен. Использовано {len(text)} символов."
300
+ status_msg += f" Число чанков: {len(state['chunks'])}."
301
+ if truncated:
302
+ status_msg += f" Текст был обрезан до {MAX_DOC_CHARS} символов для стабильной работы."
303
+
304
+ return short_summary, long_summary, state, status_msg
305
+
306
+
307
+ def explain_text_fn(passage: str, level: str, state):
308
+ """
309
+ Объяснение фрагмента простым языком.
310
+ Если фрагмент не задан — берём начало загруженного текста.
311
+ """
312
+ if (not passage or not passage.strip()) and state and state.get("text"):
313
+ passage = state["text"][:1500]
314
+
315
+ if not passage or not passage.strip():
316
+ return "Нет текста для объяснения. Сначала загрузите документ или введите фрагмент."
317
+
318
+ level_prompt = {
319
+ "Школьник": "Explain the following Russian text in simple Russian, so that a 9th grade student can understand it. Use short sentences and simple examples. Answer ONLY in Russian.",
320
+ "Студент": "Explain the following Russian text clearly and structurally for a first-year university student. Use Russian language. Answer ONLY in Russian.",
321
+ "Эксперт": "Explain the following Russian text briefly but in a professional, scientific manner. Use Russian language and appropriate terminology. Answer ONLY in Russian.",
322
+ }.get(level, "Explain the following Russian text in simple Russian. Answer ONLY in Russian.")
323
+
324
+ prompt = (
325
+ f"{level_prompt}\n\n"
326
+ f"Текст:\n{passage}\n\n"
327
+ f"Объяснение на русском:"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  )
329
+
330
+ result = chat_model(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  prompt,
332
+ max_new_tokens=256,
333
+ do_sample=False,
334
  truncation=True,
335
+ )[0]["generated_text"]
336
+
337
+ return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
+
340
+ def generate_questions_fn(difficulty: str, num_q: int, state):
341
+ """
342
+ Генерация экзаменационных вопросов по документу.
343
+ Принуждаем модель выдавать именно НУМЕРОВАННЫЙ СПИСОК
344
+ осмысленных вопросов-предложений на русском.
345
+ """
346
+ if not state or not state.get("text"):
347
  return "Сначала загрузите документ."
 
 
 
 
 
 
 
 
 
348
 
349
+ base_text = state["text"][:4000]
350
 
351
+ difficulty_en = {
352
+ "easy": "easy (basic understanding)",
353
+ "medium": "medium (conceptual understanding)",
354
+ "hard": "hard (deep analytical understanding)",
355
+ }.get(difficulty, "medium (conceptual understanding)")
356
 
357
+ prompt = (
358
+ "You are an assistant that creates exam questions in Russian.\n"
359
+ f"Difficulty level: {difficulty_en}.\n\n"
360
+ "Based on the Russian text below, create a numbered list of "
361
+ f"{num_q} exam questions in RUSSIAN.\n\n"
362
+ "Requirements:\n"
363
+ "- Each question MUST be a full sentence in Russian (not one word).\n"
364
+ "- Questions must be directly related to the text.\n"
365
+ "- Output ONLY the numbered list of questions, nothing else.\n\n"
366
+ f"Текст:\n{base_text}\n\n"
367
+ "Список вопросов на русском:\n"
368
+ "1."
369
+ )
370
 
371
+ result = chat_model(
 
 
372
  prompt,
373
+ max_new_tokens=384,
374
+ do_sample=False,
375
  truncation=True,
376
+ )[0]["generated_text"]
377
+
378
+ return result
379
+
380
+
381
+ def chat_answer_fn(message: str, chat_history: List, state):
382
+ """
383
+ Чат по документу (RAG: поиск контекста + генерация ответа).
384
+ """
385
+ if not message or not message.strip():
386
+ return chat_history, ""
387
+
388
+ if not state or not state.get("chunks"):
389
+ bot_msg = "Сначала загрузите документ на вкладке «Документ»."
390
+ chat_history = chat_history + [(message, bot_msg)]
391
+ return chat_history, ""
392
+
393
+ # Берём релевантные чанки
394
+ context_chunks = retrieve_context(message, state, top_k=4)
395
+ context = "\n\n".join(context_chunks)
396
+
397
+ if not context.strip():
398
+ bot_msg = "В документе не нашлось подходящего фрагмента для ответа на этот вопрос."
399
+ chat_history = chat_history + [(message, bot_msg)]
400
+ return chat_history, ""
401
+
402
+ prompt = (
403
+ "You are a helpful assistant that answers questions ONLY based on the provided Russian context.\n"
404
+ "Rules:\n"
405
+ "- Answer strictly in Russian.\n"
406
+ "- If the answer is not present in the context, say explicitly in Russian that the document does not contain this information.\n\n"
407
+ f"Контекст (фрагменты из документа):\n{context}\n\n"
408
+ f"Вопрос пользователя: {message}\n\n"
409
+ "Ответ на русском:"
410
  )
411
+
412
+ answer = chat_model(
413
+ prompt,
414
+ max_new_tokens=256,
415
+ do_sample=False,
416
+ truncation=True,
417
+ )[0]["generated_text"]
418
+
419
+ chat_history = chat_history + [(message, answer)]
420
+ return chat_history, ""
421
+
422
+
423
+ def clear_chat():
424
+ return [], ""
425
+
426
+
427
+ # ========= UI НА GRADIO ==========
428
+
429
+ with gr.Blocks(title="EduMultiSpace — учебный помощник (устойчивая версия)") as demo:
430
+ gr.Markdown(
431
+ """
432
+ # 📚 EduMultiSpace (устойчивая версия)
433
+
434
+ Учебный помощник на базе компактных трансформеров:
435
+ 1. Поиск по документу (эмбеддинги + RAG)
436
+ 2. Краткий и расширенный конспект
437
+ 3. Объяснение сложных фрагментов
438
+ 4. Генерация экзаменационных вопросов и чат по тексту
439
+
440
+ Для стабильности:
441
+ * Храним не более 200 000 символов текста.
442
+ * Суммаризация делается по первым ~30 000 символов.
443
+ """
444
  )
445
+
446
+ # Состояние
447
+ state = gr.State({"text": "", "chunks": [], "embeddings": None})
448
+
449
+ # --- Вкладка: Документ ---
450
+ with gr.Tab("Документ"):
451
+ with gr.Row():
452
+ file_input = gr.File(
453
+ label="Загрузите файл (.txt или .pdf)",
454
+ file_types=[".txt", ".pdf"],
455
+ )
456
+ text_input = gr.Textbox(
457
+ label="Или вставьте текст вручную",
458
+ lines=10,
459
+ placeholder="Вставьте сюда ваш текст...",
460
+ )
461
+
462
+ load_btn = gr.Button("Загрузить и проанализировать")
463
+ status_md = gr.Markdown()
464
+
465
+ short_summary_box = gr.Textbox(
466
+ label="Краткий конспект",
467
+ lines=8,
468
+ )
469
+ long_summary_box = gr.Textbox(
470
+ label="Расширенный конспект",
471
+ lines=12,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  )
473
+
474
+ load_btn.click(
475
+ load_document,
476
+ inputs=[file_input, text_input, state],
477
+ outputs=[short_summary_box, long_summary_box, state, status_md],
478
+ )
479
+
480
+ # --- Вкладка: Объяснение ---
481
+ with gr.Tab("Объяснение"):
482
+ explain_input = gr.Textbox(
483
+ label="Фрагмент для объяснения",
484
+ lines=8,
485
+ placeholder="Вставьте отрывок из документа. "
486
+ "Если оставить пустым — будет взято начало загруженного текста.",
487
+ )
488
+ level_dd = gr.Dropdown(
489
+ label="Уровень объяснения",
490
+ choices=["Школьник", "Студент", "Эксперт"],
491
+ value="Студент",
492
+ )
493
+ explain_btn = gr.Button("Объяснить проще")
494
+ explain_out = gr.Textbox(
495
+ label="Объяснение",
496
+ lines=10,
497
+ )
498
+
499
  explain_btn.click(
500
+ explain_text_fn,
501
+ inputs=[explain_input, level_dd, state],
502
+ outputs=[explain_out],
503
  )
504
+
505
+ # --- Вкладка: Вопросы к тексту ---
506
+ with gr.Tab("Вопросы"):
507
+ diff_dd = gr.Dropdown(
508
+ label="Сложность",
509
+ choices=["easy", "medium", "hard"],
510
+ value="medium",
511
  )
512
+ num_slider = gr.Slider(
513
+ label="Количество вопросов",
514
+ minimum=3,
515
+ maximum=10,
516
+ value=5,
517
+ step=1,
 
 
 
 
518
  )
519
+ gen_q_btn = gr.Button("Сгенерировать вопросы")
520
+ q_out = gr.Textbox(
521
+ label="Вопросы",
522
+ lines=12,
 
523
  )
 
 
 
 
524
 
525
+ gen_q_btn.click(
526
+ generate_questions_fn,
527
+ inputs=[diff_dd, num_slider, state],
528
+ outputs=[q_out],
529
+ )
530
+
531
+ # --- Вкладка: Чат с документом ---
532
+ with gr.Tab("Чат с документом"):
533
+ chatbot = gr.Chatbot(label="Чат по вашему документу")
534
+ msg = gr.Textbox(
535
+ label="Ваш вопрос",
536
+ lines=2,
537
+ placeholder="Задайте вопрос по загруженному тексту...",
538
+ )
539
+ send_btn = gr.Button("Отправить")
540
+ clear_btn = gr.Button("Очистить чат")
541
+
542
+ send_btn.click(
543
+ chat_answer_fn,
544
+ inputs=[msg, chatbot, state],
545
+ outputs=[chatbot, msg],
546
+ )
547
+
548
+ clear_btn.click(
549
+ clear_chat,
550
+ inputs=None,
551
+ outputs=[chatbot, msg],
552
+ )
553
+
554
+
555
  if __name__ == "__main__":
556
+ demo.launch()