muryshev commited on
Commit
fd78d64
·
1 Parent(s): 633b8bd
components/llm/prompts.py CHANGED
@@ -97,15 +97,13 @@ PROMPT_QE = """
97
  ####
98
  Инструкция для составления ответа
99
  ####
100
- Твоя задача - проанализировать чат общения между работником и сервисом помощника. Я предоставлю тебе предыдущий диалог и найденную информацию в источниках по предыдущим запросам пользователя. Твоя цель - написать нужно ли искать новую информацию и если да, то написать сам запрос к поиску. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
101
  - Отвечай ТОЛЬКО на русском языке.
102
  - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
103
  - Запрещено писать транслитом. Запрещено писать на языках не русском.
104
  - Тебе запрещено самостоятельно расшифровывать аббревиатуры.
105
  - Будь вежливым и дружелюбным.
106
  - Думай шаг за шагом.
107
- - Ответ на запрос пользователя должен быть ОДНОЗНАЧНО прописан в предыдущем диалоге, чтобы не искать новую информацию [НЕТ].
108
- - Наденная ранее информация находится внутри <search-results></search-results>.
109
  - Запросы пользователя находятся после "user:".
110
  - Ответы сервиса помощника находятся после "assistant:".
111
  - Иногда пользователь может задавать вопросы, которые не касаются тематики рекрутинга. В таких случаях не нужно искать информацию.
@@ -120,7 +118,7 @@ PROMPT_QE = """
120
  3. Напиши рассуждения о том как сформулировать запрос в поиск. Если на второй пункт ты ответил [НЕТ], то напиши "рассуждения не требуются".
121
  4. Напиши запрос в поиск внутри квадратных скобочек []. Если на второй пункт ты ответил [НЕТ], то напиши "[]".
122
  Конец плана.
123
- Структура твоего ответа: "
124
  1. 'пункт 1'
125
  2. '[ДА] или [НЕТ]'
126
  3. 'пункт 3'
@@ -130,30 +128,24 @@ PROMPT_QE = """
130
  Пример 1
131
  ####
132
  user: А в какие сроки на меня нужно направить характеристику для аттестации?
133
- <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
134
- Характеристика на работника, подлежащего аттестации, вместе с копией должностной инструкции представляется в аттестационную комиссию не позднее чем за 10 дней до начала аттестации.</search-results>
135
- assistant: Не позднее чем за 10 дней до начала аттестации в аттестационную комиссию нужно направить характеристику вместе с копией должностной инструкции.
136
  user: Я волнуюсь. А как она проводится?
137
- <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
138
- 12-1. Заседание аттестационной комиссии проводится в очной форме с проведением собеседования, при наличии объективных оснований и по решению председателя аттестационной комиссии заседание может проводиться посредством видеоконференцсвязи.
139
- (п. 12-1 введен Решением Правления ОАО "Белагропромбанк" от 24.09.2020 N 80)
140
- 13. Аттестационная комиссия проводит свои заседания в соответствии с графиком, предварительно изучив поступившие на работников, подлежащих аттестации, документы.
141
- На заседании комиссии ведется протокол, который подписывается председателем и секретарем комиссии, являющимися одновременно членами комиссии с правом голоса.</search-results>
142
- assistant: Не переживайте. Аттестация проводится в очной форме в виде собеседования. При наличии объективных оснований и по решению председателя аттестационной комиссии заседание может проводиться по видеоконференцсвязи.
143
  user: А кто будет участвовать?
144
  ####
145
  Вывод:
146
- 1. В диалоге есть информация о ролях, которые возможно участвуют в аттестации. Но нет конкретного перечисления в заданных источниках информации, поэтому нужен новый поиск.
147
  2. [ДА]
148
- 3. Итоговый запрос кто будет участвовать?". Но он не даёт полной картины из-за потери контекста. Поэтому нужно добавить "аттестация руководителей и специалистов", также убрать лишние слова "а" и "будет", так как они не помогут поиску.
149
- 4. [Кто участвует в аттестации руководителей и специалистов?]
150
  ####
151
  Пример 2
152
  ####
153
  user: Здравствуйте. Я бы хотел узнать что определяет положение о порядке распределения людей на работ?
154
  ####
155
  Вывод:
156
- 1. В приведённом примере только запрос пользователя. Результатов поиска нет, поэтому нужно искать.
157
  2. [ДА]
158
  3. Запрос сформулирован почти корректно. Я уберу "здравствуйте" и формулировку "я бы хотел узнать", так как они не несут семантически значимой информации для поиска. Также слово "работ" перепишу корректно в "работу".
159
  4. [Что определяет положение о порядке распределения людей на работу?]
@@ -161,13 +153,9 @@ user: Здравствуйте. Я бы хотел узнать что опре
161
  Пример 3
162
  ####
163
  user: Привет! Кто ты?
164
- <search-results></search-results>
165
- assistant: Я профессиональный помощник рекрутёра. Вы можете задавать мне любые вопросы по подготовленным документам.
166
- user: А если я задам вопрос не по документам? Ты мне наврёшь?
167
- <search-results></search-results>
168
  assistant: Нет, что вы. Я формирую ответ только по найденной из документов информации. Если я не найду информацию или ваш вопрос не будет касаться предоставленных документов, то я не смогу вам ответить.
169
  user: Где питается слон?
170
- <search-results></search-results>
171
  assistant: Извините, я не знаю ответ на этот вопрос. Он не касается рекрутинга. Попробуйте переформулировать.
172
  user: Что такое корпоративное управление банка? Зачем нужны комитеты? Где собака зарыта? Откуда ты всё знаешь?
173
  ####
 
97
  ####
98
  Инструкция для составления ответа
99
  ####
100
+ Твоя задача - проанализировать чат общения между работником и сервисом помощника. Я предоставлю тебе предыдущий диалог по предыдущим запросам пользователя. Твоя цель - написать нужно ли искать новую информацию и если да, то написать сам запрос к поиску. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
101
  - Отвечай ТОЛЬКО на русском языке.
102
  - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
103
  - Запрещено писать транслитом. Запрещено писать на языках не русском.
104
  - Тебе запрещено самостоятельно расшифровывать аббревиатуры.
105
  - Будь вежливым и дружелюбным.
106
  - Думай шаг за шагом.
 
 
107
  - Запросы пользователя находятся после "user:".
108
  - Ответы сервиса помощника находятся после "assistant:".
109
  - Иногда пользователь может задавать вопросы, которые не касаются тематики рекрутинга. В таких случаях не нужно искать информацию.
 
118
  3. Напиши рассуждения о том как сформулировать запрос в поиск. Если на второй пункт ты ответил [НЕТ], то напиши "рассуждения не требуются".
119
  4. Напиши запрос в поиск внутри квадратных скобочек []. Если на второй пункт ты ответил [НЕТ], то напиши "[]".
120
  Конец плана.
121
+ Структура твоего ответа:"
122
  1. 'пункт 1'
123
  2. '[ДА] или [НЕТ]'
124
  3. 'пункт 3'
 
128
  Пример 1
129
  ####
130
  user: А в какие сроки на меня нужно направить характеристику для аттестации?
131
+ assistant: Согласно положению об аттестации руководителей и специалистов ОАО Белагропромбанка не позднее чем за 10 дней до начала аттестации в аттестационную комиссию нужно направить характеристику вместе с копией должностной инструкции.
 
 
132
  user: Я волнуюсь. А как она проводится?
133
+ assistant: Не переживайте, всё будет хорошо.
134
+ Согласно п. 12-1 положению об аттестации руководителей и специалистов ОАО Белагропромбанка аттестация проводится в очной форме в виде собеседования. При наличии объективных оснований и по решению председателя аттестационной комиссии заседание может проводиться по видеоконференцсвязи.
 
 
 
 
135
  user: А кто будет участвовать?
136
  ####
137
  Вывод:
138
+ 1. Пользователь задаёт вопрос о участниках аттестации, что является логическим продолжением предыдущих вопросов о порядке и сроках аттестации. Этот вопрос касается основной тематики, поэтому нужно искать информацию.
139
  2. [ДА]
140
+ 3. Запрос следует сформулировать так, чтобы он был максимально конкретным и касался состава участников аттестации. Это может включать в себя вопросы о членах аттестационной комиссии, роли председателя и секретаря, а также о других возможных участниках процесса.
141
+ 4. [Состав участников аттестации руководителей и специалистов в банке]
142
  ####
143
  Пример 2
144
  ####
145
  user: Здравствуйте. Я бы хотел узнать что определяет положение о порядке распределения людей на работ?
146
  ####
147
  Вывод:
148
+ 1. В приведённом примере только запрос пользователя. Результатов поиска нет, запрос касается моей тематики, поэтому нужно искать.
149
  2. [ДА]
150
  3. Запрос сформулирован почти корректно. Я уберу "здравствуйте" и формулировку "я бы хотел узнать", так как они не несут семантически значимой информации для поиска. Также слово "работ" перепишу корректно в "работу".
151
  4. [Что определяет положение о порядке распределения людей на работу?]
 
153
  Пример 3
154
  ####
155
  user: Привет! Кто ты?
156
+ assistant: Я профессиональный помощник менеджера по персоналу. Вы можете задавать мне любые вопросы по подготовленным документам.
 
 
 
157
  assistant: Нет, что вы. Я формирую ответ только по найденной из документов информации. Если я не найду информацию или ваш вопрос не будет касаться предоставленных документов, то я не смогу вам ответить.
158
  user: Где питается слон?
 
159
  assistant: Извините, я не знаю ответ на этот вопрос. Он не касается рекрутинга. Попробуйте переформулировать.
160
  user: Что такое корпоративное управление банка? Зачем нужны комитеты? Где собака зарыта? Откуда ты всё знаешь?
161
  ####
components/services/dataset.py CHANGED
@@ -1,13 +1,15 @@
1
  import asyncio
2
- from functools import partial
3
  import json
4
  import logging
5
  import os
6
  import shutil
7
  import zipfile
8
  from datetime import datetime
 
9
  from pathlib import Path
10
 
 
 
11
  import torch
12
  from fastapi import BackgroundTasks, HTTPException, UploadFile
13
  from ntr_fileparser import ParsedDocument, UniversalParser
@@ -61,14 +63,21 @@ class DatasetService:
61
  try:
62
  active_dataset = self.get_current_dataset()
63
  if active_dataset:
64
- logger.info(f"Performing initial cache load for active dataset {active_dataset.id}")
 
 
65
  # Вызываем метод сервиса сущностей для построения кеша
66
  self.entity_service.build_cache(active_dataset.id)
67
  else:
68
- logger.warning("No active dataset found during DatasetService initialization.")
 
 
69
  except Exception as e:
70
  # Логгируем ошибку, но не прерываем инициализацию сервиса
71
- logger.error(f"Failed initial cache load during DatasetService initialization: {e}", exc_info=True)
 
 
 
72
 
73
  logger.info("DatasetService initialized")
74
 
@@ -224,11 +233,13 @@ class DatasetService:
224
  raise HTTPException(
225
  status_code=403, detail='Active dataset cannot be deleted'
226
  )
227
-
228
  # Инвалидируем кеш перед удалением данных (больше не нужен ID)
229
  self.entity_service.invalidate_cache()
230
 
231
- session.query(EntityModel).filter(EntityModel.dataset_id == dataset_id).delete()
 
 
232
  session.delete(dataset)
233
  session.commit()
234
 
@@ -253,7 +264,7 @@ class DatasetService:
253
  )
254
  old_active_dataset_id = active_dataset.id if active_dataset else None
255
 
256
- self.apply_draft(dataset)
257
  dataset.is_draft = False
258
  dataset.is_active = True
259
  if active_dataset:
@@ -304,7 +315,9 @@ class DatasetService:
304
  if old_active_dataset_id:
305
  self.entity_service.invalidate_cache()
306
  await self.entity_service.build_or_rebuild_cache_async(dataset_id)
307
- logger.info(f"Caches updated after activating non-draft dataset {dataset_id}")
 
 
308
 
309
  return self.get_dataset(dataset_id)
310
 
@@ -374,13 +387,13 @@ class DatasetService:
374
 
375
  return self.get_dataset(dataset.id)
376
 
377
- def apply_draft(
378
  self,
379
  dataset: Dataset,
380
  ) -> None:
381
  """
382
  Сохранить черновик как полноценный датасет.
383
- Вызывает асинхронную обработку документов.
384
 
385
  Args:
386
  dataset: Датасет для применения
@@ -419,7 +432,9 @@ class DatasetService:
419
  doc_dataset_link.document for doc_dataset_link in dataset.documents
420
  ]
421
 
422
- async def process_single_document(document: Document):
 
 
423
  path = self.documents_path / f'{document.id}.{document.source_format}'
424
  try:
425
  parsed = self.parser.parse_by_path(str(path))
@@ -427,25 +442,55 @@ class DatasetService:
427
  logger.warning(
428
  f"Failed to parse document {document.id} at path {path}"
429
  )
430
- return
431
  parsed.name = document.title
432
- await self.entity_service.process_document(
433
- parsed,
434
- dataset.id,
435
- progress_callback=progress_callback, # Callback остается синхронным
436
  )
 
 
437
  except Exception as e:
438
  logger.error(
439
  f"Error processing document {document.id} in apply_draft: {e}",
440
  exc_info=True,
441
  )
 
442
 
443
  async def main_processing():
444
  tasks = [process_single_document(doc) for doc in documents]
445
- await asyncio.gather(*tasks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
 
447
  try:
448
- asyncio.run(main_processing())
449
  finally:
450
  if TMP_PATH.exists():
451
  TMP_PATH.unlink()
@@ -589,10 +634,8 @@ class DatasetService:
589
  try:
590
  source_format = get_source_format(str(subpath))
591
  path = documents_path / subpath
592
- parsed: ParsedDocument | None = self.parser.parse_by_path(
593
- str(path)
594
- )
595
-
596
  if 'Приложение' in parsed.name:
597
  parsed.name = path.parent.name + ' ' + parsed.name
598
 
 
1
  import asyncio
 
2
  import json
3
  import logging
4
  import os
5
  import shutil
6
  import zipfile
7
  from datetime import datetime
8
+ from functools import partial
9
  from pathlib import Path
10
 
11
+ from ntr_text_fragmentation import LinkerEntity
12
+ import numpy as np
13
  import torch
14
  from fastapi import BackgroundTasks, HTTPException, UploadFile
15
  from ntr_fileparser import ParsedDocument, UniversalParser
 
63
  try:
64
  active_dataset = self.get_current_dataset()
65
  if active_dataset:
66
+ logger.info(
67
+ f"Performing initial cache load for active dataset {active_dataset.id}"
68
+ )
69
  # Вызываем метод сервиса сущностей для построения кеша
70
  self.entity_service.build_cache(active_dataset.id)
71
  else:
72
+ logger.warning(
73
+ "No active dataset found during DatasetService initialization."
74
+ )
75
  except Exception as e:
76
  # Логгируем ошибку, но не прерываем инициализацию сервиса
77
+ logger.error(
78
+ f"Failed initial cache load during DatasetService initialization: {e}",
79
+ exc_info=True,
80
+ )
81
 
82
  logger.info("DatasetService initialized")
83
 
 
233
  raise HTTPException(
234
  status_code=403, detail='Active dataset cannot be deleted'
235
  )
236
+
237
  # Инвалидируем кеш перед удалением данных (больше не нужен ID)
238
  self.entity_service.invalidate_cache()
239
 
240
+ session.query(EntityModel).filter(
241
+ EntityModel.dataset_id == dataset_id
242
+ ).delete()
243
  session.delete(dataset)
244
  session.commit()
245
 
 
264
  )
265
  old_active_dataset_id = active_dataset.id if active_dataset else None
266
 
267
+ await self.apply_draft(dataset)
268
  dataset.is_draft = False
269
  dataset.is_active = True
270
  if active_dataset:
 
315
  if old_active_dataset_id:
316
  self.entity_service.invalidate_cache()
317
  await self.entity_service.build_or_rebuild_cache_async(dataset_id)
318
+ logger.info(
319
+ f"Caches updated after activating non-draft dataset {dataset_id}"
320
+ )
321
 
322
  return self.get_dataset(dataset_id)
323
 
 
387
 
388
  return self.get_dataset(dataset.id)
389
 
390
+ async def apply_draft(
391
  self,
392
  dataset: Dataset,
393
  ) -> None:
394
  """
395
  Сохранить черновик как полноценный датасет.
396
+ Вызывает асинхронную обработку документов и батчевую вставку в БД.
397
 
398
  Args:
399
  dataset: Датасет для применения
 
432
  doc_dataset_link.document for doc_dataset_link in dataset.documents
433
  ]
434
 
435
+ async def process_single_document(
436
+ document: Document,
437
+ ) -> tuple[list[LinkerEntity], dict[str, np.ndarray]] | None:
438
  path = self.documents_path / f'{document.id}.{document.source_format}'
439
  try:
440
  parsed = self.parser.parse_by_path(str(path))
 
442
  logger.warning(
443
  f"Failed to parse document {document.id} at path {path}"
444
  )
445
+ return None
446
  parsed.name = document.title
447
+
448
+ # Вызываем метод EntityService для подготовки данных
449
+ result = await self.entity_service.prepare_document_data_async(
450
+ parsed, progress_callback=None
451
  )
452
+ return result
453
+
454
  except Exception as e:
455
  logger.error(
456
  f"Error processing document {document.id} in apply_draft: {e}",
457
  exc_info=True,
458
  )
459
+ return None
460
 
461
  async def main_processing():
462
  tasks = [process_single_document(doc) for doc in documents]
463
+ results = await asyncio.gather(*tasks)
464
+
465
+ # Агрегируем результаты
466
+ all_entities_to_add = []
467
+ all_embeddings_dict = {}
468
+ processed_count = 0
469
+ for result in results:
470
+ if result is not None:
471
+ doc_entities, doc_embeddings = result
472
+ all_entities_to_add.extend(doc_entities)
473
+ all_embeddings_dict.update(doc_embeddings)
474
+ processed_count += 1
475
+
476
+ logger.info(
477
+ f"Finished processing {processed_count}/{len(documents)} documents."
478
+ )
479
+ logger.info(f"Total entities to add: {len(all_entities_to_add)}")
480
+ logger.info(f"Total embeddings to add: {len(all_embeddings_dict)}")
481
+
482
+ # Выполняем батчевую вставку
483
+ if all_entities_to_add:
484
+ logger.info("Starting batch insertion into database...")
485
+ # Вызов метода EntityService
486
+ await self.entity_service.add_entities_batch_async(
487
+ dataset.id, all_entities_to_add, all_embeddings_dict
488
+ )
489
+ else:
490
+ logger.info("No entities to insert.")
491
 
492
  try:
493
+ await main_processing()
494
  finally:
495
  if TMP_PATH.exists():
496
  TMP_PATH.unlink()
 
634
  try:
635
  source_format = get_source_format(str(subpath))
636
  path = documents_path / subpath
637
+ parsed: ParsedDocument | None = self.parser.parse_by_path(str(path))
638
+
 
 
639
  if 'Приложение' in parsed.name:
640
  parsed.name = path.parent.name + ' ' + parsed.name
641
 
components/services/dialogue.py CHANGED
@@ -98,8 +98,8 @@ class DialogueService:
98
  Args:
99
  message: Сообщение для форматирования
100
  """
101
- if message.searchResults:
102
- return f'{message.role}: {message.content}\n<search-results>\n{message.searchResults}\n</search-results>'
103
  return f'{message.role}: {message.content}'
104
 
105
  @staticmethod
 
98
  Args:
99
  message: Сообщение для форматирования
100
  """
101
+ # if message.searchResults:
102
+ # return f'{message.role}: {message.content}\n<search-results>\n{message.searchResults}\n</search-results>'
103
  return f'{message.role}: {message.content}'
104
 
105
  @staticmethod
components/services/entity.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import logging
2
  from typing import Callable, Optional
3
  from uuid import UUID
@@ -5,7 +6,7 @@ from uuid import UUID
5
  import numpy as np
6
  from ntr_fileparser import ParsedDocument
7
  from ntr_text_fragmentation import (EntitiesExtractor, EntityRepository,
8
- InjectionBuilder, InMemoryEntityRepository)
9
 
10
  from common.configuration import Configuration
11
  from components.dbo.chunk_repository import ChunkRepository
@@ -76,7 +77,7 @@ class EntityService:
76
  def invalidate_cache(self) -> None:
77
  """Инвалидирует (удаляет) текущий кеш в памяти."""
78
  if self._in_memory_cache:
79
- self._in_memory_cache.clear()
80
  self._cached_dataset_id = None
81
  else:
82
  logger.info("In-memory кеш уже пуст. Ничего не делаем.")
@@ -210,6 +211,79 @@ class EntityService:
210
 
211
  logger.info(f"Added {len(entities)} entities to dataset {dataset_id}")
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  async def build_text_async(
214
  self,
215
  entities: list[str],
 
1
+ import asyncio
2
  import logging
3
  from typing import Callable, Optional
4
  from uuid import UUID
 
6
  import numpy as np
7
  from ntr_fileparser import ParsedDocument
8
  from ntr_text_fragmentation import (EntitiesExtractor, EntityRepository,
9
+ InjectionBuilder, InMemoryEntityRepository, LinkerEntity)
10
 
11
  from common.configuration import Configuration
12
  from components.dbo.chunk_repository import ChunkRepository
 
77
  def invalidate_cache(self) -> None:
78
  """Инвалидирует (удаляет) текущий кеш в памяти."""
79
  if self._in_memory_cache:
80
+ self._in_memory_cache = None
81
  self._cached_dataset_id = None
82
  else:
83
  logger.info("In-memory кеш уже пуст. Ничего не делаем.")
 
211
 
212
  logger.info(f"Added {len(entities)} entities to dataset {dataset_id}")
213
 
214
+ async def add_entities_batch_async(
215
+ self,
216
+ dataset_id: int,
217
+ entities: list[LinkerEntity],
218
+ embeddings: dict[str, np.ndarray],
219
+ ):
220
+ """Асинхронно добавляет батч сущностей и их эмбеддингов в БД."""
221
+ if not entities:
222
+ logger.info("add_entities_batch_async called with empty entities list. Nothing to add.")
223
+ return
224
+
225
+ logger.info(f"Starting batch insertion of {len(entities)} entities for dataset {dataset_id}...")
226
+ try:
227
+ await asyncio.to_thread(
228
+ self.chunk_repository.add_entities,
229
+ entities,
230
+ dataset_id,
231
+ embeddings
232
+ )
233
+ logger.info(f"Batch insertion of {len(entities)} entities finished for dataset {dataset_id}.")
234
+ except Exception as e:
235
+ logger.error(
236
+ f"Error during batch insertion for dataset {dataset_id}: {e}",
237
+ exc_info=True,
238
+ )
239
+ raise e
240
+
241
+ async def prepare_document_data_async(
242
+ self,
243
+ document: ParsedDocument,
244
+ progress_callback: Optional[Callable] = None,
245
+ ) -> tuple[list[LinkerEntity], dict[str, np.ndarray]]:
246
+ """Асинхронно извлекает сущности и векторы для документа.
247
+
248
+ Не сохраняет данные в репозиторий, а возвращает их для последующей
249
+ батчевой обработки.
250
+
251
+ Args:
252
+ document: Документ для обработки.
253
+ progress_callback: Функция для отслеживания прогресса векторизации.
254
+
255
+ Returns:
256
+ Кортеж: (список извлеченных LinkerEntity, словарь эмбеддингов {id_str: embedding}).
257
+ """
258
+ logger.debug(f"Preparing data for document {document.name}")
259
+
260
+ # 1. Извлечение сущностей
261
+ if 'Приложение' in document.name:
262
+ entities = await self.appendices_extractor.extract_async(document)
263
+ else:
264
+ entities = await self.main_extractor.extract_async(document)
265
+
266
+ # 2. Векторизация (если нужно)
267
+ filtering_entities = [
268
+ entity for entity in entities if entity.in_search_text is not None
269
+ ]
270
+ filtering_texts = [entity.in_search_text for entity in filtering_entities]
271
+
272
+ embeddings = self.vectorizer.vectorize(filtering_texts, progress_callback)
273
+
274
+ embeddings_dict = {}
275
+ if embeddings is not None:
276
+ embeddings_dict = {
277
+ str(entity.id): embedding
278
+ for entity, embedding in zip(filtering_entities, embeddings)
279
+ if embedding is not None
280
+ }
281
+ else:
282
+ logger.warning(f"Vectorizer returned None for document {document.name}")
283
+
284
+ logger.debug(f"Prepared data for document {document.name}: {len(entities)} entities, {len(embeddings_dict)} embeddings.")
285
+ return entities, embeddings_dict
286
+
287
  async def build_text_async(
288
  self,
289
  entities: list[str],