muryshev commited on
Commit
be03119
·
1 Parent(s): e7cad23
Files changed (29) hide show
  1. common/configuration.py +1 -1
  2. common/dependencies.py +14 -6
  3. components/dbo/chunk_repository.py +64 -0
  4. components/llm/prompts.py +159 -0
  5. components/llm/utils.py +18 -12
  6. components/services/dataset.py +38 -7
  7. components/services/dialogue.py +8 -16
  8. components/services/entity.py +116 -25
  9. components/services/search_metrics.py +9 -3
  10. config_dev.yaml +2 -2
  11. lib/extractor/ntr_text_fragmentation/additors/tables/table_processor.py +91 -2
  12. lib/extractor/ntr_text_fragmentation/additors/tables_processor.py +39 -2
  13. lib/extractor/ntr_text_fragmentation/chunking/chunking_strategy.py +13 -0
  14. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/__init__.py +23 -4
  15. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/__init__.py +18 -0
  16. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_chunk.py +66 -0
  17. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_paragraph_chunking.py +355 -0
  18. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_sentence_chunking.py +415 -0
  19. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_utils.py +86 -0
  20. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/paragraph_chunking.py +180 -0
  21. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/sentence_chunking.py +261 -0
  22. lib/extractor/ntr_text_fragmentation/core/injection_builder.py +162 -3
  23. lib/extractor/ntr_text_fragmentation/integrations/sqlalchemy/sqlalchemy_repository.py +36 -0
  24. lib/extractor/ntr_text_fragmentation/repositories/entity_repository.py +36 -0
  25. lib/extractor/ntr_text_fragmentation/repositories/in_memory_repository.py +0 -2
  26. lib/extractor/pyproject.toml +2 -1
  27. routes/dataset.py +1 -1
  28. routes/entity.py +19 -11
  29. routes/llm.py +81 -34
common/configuration.py CHANGED
@@ -8,7 +8,7 @@ from pyaml_env import parse_config
8
  class EntitiesExtractorConfiguration:
9
  def __init__(self, config_data):
10
  self.strategy_name = str(config_data['strategy_name'])
11
- self.strategy_params: dict = config_data['strategy_params']
12
  self.process_tables = bool(config_data['process_tables'])
13
  self.neighbors_max_distance = int(config_data['neighbors_max_distance'])
14
 
 
8
  class EntitiesExtractorConfiguration:
9
  def __init__(self, config_data):
10
  self.strategy_name = str(config_data['strategy_name'])
11
+ self.strategy_params: dict | None = config_data['strategy_params']
12
  self.process_tables = bool(config_data['process_tables'])
13
  self.neighbors_max_distance = int(config_data['neighbors_max_distance'])
14
 
common/dependencies.py CHANGED
@@ -19,6 +19,7 @@ from components.services.document import DocumentService
19
  from components.services.entity import EntityService
20
  from components.services.llm_config import LLMConfigService
21
  from components.services.llm_prompt import LlmPromptService
 
22
 
23
 
24
  def get_config() -> Configuration:
@@ -117,17 +118,24 @@ def get_document_service(
117
 
118
 
119
  def get_dialogue_service(
120
- config: Annotated[Configuration, Depends(get_config)],
121
- entity_service: Annotated[EntityService, Depends(get_entity_service)],
122
- dataset_service: Annotated[DatasetService, Depends(get_dataset_service)],
123
  llm_api: Annotated[DeepInfraApi, Depends(get_llm_service)],
124
  llm_config_service: Annotated[LLMConfigService, Depends(get_llm_config_service)],
125
  ) -> DialogueService:
126
  """Получение сервиса для работы с диалогами через DI."""
127
  return DialogueService(
128
- config=config,
129
- entity_service=entity_service,
130
- dataset_service=dataset_service,
131
  llm_api=llm_api,
132
  llm_config_service=llm_config_service,
133
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from components.services.entity import EntityService
20
  from components.services.llm_config import LLMConfigService
21
  from components.services.llm_prompt import LlmPromptService
22
+ from components.services.search_metrics import SearchMetricsService
23
 
24
 
25
  def get_config() -> Configuration:
 
118
 
119
 
120
  def get_dialogue_service(
 
 
 
121
  llm_api: Annotated[DeepInfraApi, Depends(get_llm_service)],
122
  llm_config_service: Annotated[LLMConfigService, Depends(get_llm_config_service)],
123
  ) -> DialogueService:
124
  """Получение сервиса для работы с диалогами через DI."""
125
  return DialogueService(
 
 
 
126
  llm_api=llm_api,
127
  llm_config_service=llm_config_service,
128
  )
129
+
130
+
131
+ def get_search_metrics_service(
132
+ entity_service: Annotated[EntityService, Depends(get_entity_service)],
133
+ config: Annotated[Configuration, Depends(get_config)],
134
+ dialogue_service: Annotated[DialogueService, Depends(get_dialogue_service)],
135
+ ) -> SearchMetricsService:
136
+ """Получение сервиса для расчета метрик поиска через DI."""
137
+ return SearchMetricsService(
138
+ entity_service=entity_service,
139
+ config=config,
140
+ dialogue_service=dialogue_service,
141
+ )
components/dbo/chunk_repository.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import logging
2
  from uuid import UUID
3
 
@@ -114,6 +115,16 @@ class ChunkRepository(SQLAlchemyEntityRepository):
114
  session.add_all(db_entities_to_add)
115
  session.commit()
116
 
 
 
 
 
 
 
 
 
 
 
117
  def get_searching_entities(
118
  self,
119
  dataset_id: int,
@@ -163,6 +174,49 @@ class ChunkRepository(SQLAlchemyEntityRepository):
163
  # Возвращаем результаты после закрытия сессии
164
  return linker_entities, embeddings_list
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  def count_entities_by_dataset_id(self, dataset_id: int) -> int:
167
  """
168
  Подсчитывает общее количество сущностей для указанного датасета.
@@ -182,3 +236,13 @@ class ChunkRepository(SQLAlchemyEntityRepository):
182
  )
183
  count = session.execute(stmt).scalar_one()
184
  return count
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  import logging
3
  from uuid import UUID
4
 
 
115
  session.add_all(db_entities_to_add)
116
  session.commit()
117
 
118
+ async def add_entities_async(
119
+ self,
120
+ entities: list[LinkerEntity],
121
+ dataset_id: int,
122
+ embeddings: dict[str, np.ndarray] | None = None,
123
+ ):
124
+ """Асинхронно добавляет список сущностей LinkerEntity в базу данных."""
125
+ # TODO: Реализовать с использованием async-сессии
126
+ await asyncio.to_thread(self.add_entities, entities, dataset_id, embeddings)
127
+
128
  def get_searching_entities(
129
  self,
130
  dataset_id: int,
 
174
  # Возвращаем результаты после закрытия сессии
175
  return linker_entities, embeddings_list
176
 
177
+ async def get_searching_entities_async(
178
+ self,
179
+ dataset_id: int,
180
+ ) -> tuple[list[LinkerEntity], list[np.ndarray]]:
181
+ """Асинхронно получает сущности для поиска вместе с эмбеддингами."""
182
+ # TODO: Реализовать с использованием async-сессии
183
+ return await asyncio.to_thread(self.get_searching_entities, dataset_id)
184
+
185
+ def get_all_entities_for_dataset(self, dataset_id: int) -> list[LinkerEntity]:
186
+ """
187
+ Получает все сущности для указанного датасета.
188
+
189
+ Args:
190
+ dataset_id: ID датасета.
191
+
192
+ Returns:
193
+ Список всех LinkerEntity для данного датасета.
194
+ """
195
+ entity_model = self._entity_model_class
196
+ linker_entities = []
197
+
198
+ with self.db() as session:
199
+ stmt = select(entity_model).where(
200
+ entity_model.dataset_id == dataset_id
201
+ )
202
+ db_models = session.execute(stmt).scalars().all()
203
+
204
+ # Переносим цикл внутрь сессии для маппинга
205
+ for model in db_models:
206
+ try:
207
+ linker_entity = self._map_db_entity_to_linker_entity(model)
208
+ linker_entities.append(linker_entity)
209
+ except Exception as e:
210
+ logger.error(f"Error mapping entity {getattr(model, 'uuid', 'N/A')} in dataset {dataset_id}: {e}")
211
+
212
+ logger.info(f"Loaded {len(linker_entities)} entities for dataset {dataset_id}")
213
+ return linker_entities
214
+
215
+ async def get_all_entities_for_dataset_async(self, dataset_id: int) -> list[LinkerEntity]:
216
+ """Асинхронно получает все сущности для указанного датасета."""
217
+ # TODO: Реализовать с использованием async-сессии
218
+ return await asyncio.to_thread(self.get_all_entities_for_dataset, dataset_id)
219
+
220
  def count_entities_by_dataset_id(self, dataset_id: int) -> int:
221
  """
222
  Подсчитывает общее количество сущностей для указанного датасета.
 
236
  )
237
  count = session.execute(stmt).scalar_one()
238
  return count
239
+
240
+ async def count_entities_by_dataset_id_async(self, dataset_id: int) -> int:
241
+ """Асинхронно подсчитывает общее количество сущностей для датасета."""
242
+ # TODO: Реализовать с использованием async-сессии
243
+ return await asyncio.to_thread(self.count_entities_by_dataset_id, dataset_id)
244
+
245
+ async def get_entities_by_ids_async(self, entity_ids: list[UUID]) -> list[LinkerEntity]:
246
+ """Асинхронно получить сущности по списку ID."""
247
+ # TODO: Реализовать с использованием async-сессии
248
+ return await asyncio.to_thread(self.get_entities_by_ids, entity_ids)
components/llm/prompts.py CHANGED
@@ -362,3 +362,162 @@ __.__.20__ N__-__/__
362
  ####
363
  Вывод:
364
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  ####
363
  Вывод:
364
  """
365
+
366
+
367
+ PROMPT_APPENDICES = """
368
+ Ты профессиональный банковский менеджер по персоналу
369
+ ####
370
+ Инструкция для составления ответа
371
+ ####
372
+ Твоя задача - проанализировать приложение к документу, которое я тебе предоставлю и выдать всю его суть, не теряя ключевую информацию. Я предоставлю тебе приложение из документов. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
373
+ - Отвечай ТОЛЬКО на русском языке.
374
+ - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
375
+ - Запрещено писать транслитом. Запрещено писать на языках не русском.
376
+ - Тебе запрещено самостоятельно расшифровывать аббревиатуры.
377
+ - Думай шаг за шагом.
378
+ - Вначале порассуждай о смысле приложения, затем напиши только его суть.
379
+ - Заключи всю суть приложения в [квадратные скобки].
380
+ - Приложение может быть в виде таблицы - в таком случае тебе нужно извлечь самую важную информацию и описать эту таблицу.
381
+ - Приложение может быть в виде шаблона для заполнения - в таком случае тебе нужно описать подробно для чего этот шаблон, а также перечислить основные поля шаблона.
382
+ - Если приложение является формой или шаблоном, то явно укажи что оно "форма (шаблон)" в сути приложения.
383
+ - Если ты не понимаешь где приложение и хочешь выдать ошибку, то внутри [квадратных скобок] вместо текста сути приложения напиши %%. Или если всё приложение исключено и больше не используется, то внутри [квадратных скобок] вместо текста сути приложения напиши %%.
384
+ - Если всё приложение является семантически значимой информацией, а не шаблоном (формой), то перепиши его в [квадратных скобок].
385
+ - Четыре #### - это разделение смысловых областей. Три ### - это начало строки таблицы.
386
+ Конец основных правил. Ты действуешь по плану:
387
+ 1. Изучи всю предоставленную тебе информацию. Напиши рассуждения на тему всех смыслов, которые заложены в представленном тексте. Поразмышляй как ты будешь давать ответ сути приложения.
388
+ 2. Напиши саму суть внутри [квадратных скобок].
389
+ Конец плана.
390
+ Структура твоего ответа:"
391
+ 1. 'пункт 1'
392
+ 2. [суть приложения]
393
+ "
394
+ ####
395
+ Пример 1
396
+ ####
397
+ [Источник] - Коллективный договор "Белагропромбанка"
398
+ Приложение 3.
399
+ Наименование профессии, нормы выдачи смывающих и обезвреживающих средств <17> из расчета на одного работника, в месяц
400
+ --------------------------------
401
+ <17> К смывающим и обезвреживающим средствам относятся мыло или аналогичные по действию смывающие средства (постановление Министерства труда и социальной защиты Республики Беларусь от 30 декабря 2008 г. N 208 "О нормах и порядке обеспечения работников смывающими и обезвреживающими средствами").
402
+ ### Строка 1
403
+ - Наименование профессии: Водитель автомобиля
404
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
405
+
406
+ ### Строка 2
407
+ - Наименование профессии: Заведующий хозяйством
408
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
409
+
410
+ ### Строка 3
411
+ - Наименование профессии: Механик
412
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
413
+
414
+ ### Строка 4
415
+ - Наименование профессии: Рабочий по комплексному обслуживанию и ремонту здания
416
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
417
+
418
+ ### Строка 5
419
+ - Наименование профессии: Слесарь по ремонту автомобилей
420
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
421
+
422
+ ### Строка 6
423
+ - Наименование профессии: Слесарь-сантехник
424
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
425
+ ####
426
+ Вывод:
427
+ 1. В данном тексте есть название, которое отражает основной смысл. Я перепишу название, привязав его к номеру приложения. Также есть таблица, в которой содержится важная информация. Я перепишу суть таблицы в сокращённом варианте, т.к. значения поля по нормам выдачи во всей таблице одинаковое.
428
+ 2. [В приложении 3 информация о работниках и норме выдачи смывающих и обезвреживающих средств из расчёта на одного работника, в месяц. К подобным средствам относится мыло и его аналоги. Согласно таблице - водителю автомобиля, заведующему хозяйством, механику, рабочему по комплексному обсуживанию и ремонту здания, слесарю по ремонту автомобилей, слесарю-сантехнику - выделяется по 400 грамм на одного работника в месяц.]
429
+ ####
430
+ Пример 2
431
+ ####
432
+ [Источник] - Положение об обучении и развитии работников ОАО Белагропромбанк
433
+ Приложение 1.
434
+ Список работников региональной дирекции ОАО "Белагропромбанк", принявших
435
+ участие в обучающих мероприятиях, проведенных сторонними организациями в
436
+ _____________ 20__ года
437
+ месяц
438
+ ### Строка 1
439
+ - N:
440
+ - ФИО работника:
441
+ - Должность работника:
442
+ - Название обучающего мероприятия, форума, конференции:
443
+ - Наименование обучающей организации:
444
+ - Сроки обучения:
445
+ - Стоимость обучения, бел. руб.:
446
+
447
+ ### Строка 2
448
+ - N:
449
+ - ФИО работника:
450
+ - Должность работника:
451
+ - Название обучающего мероприятия, форума, конференции:
452
+ - Наименование обучающей организации:
453
+ - Сроки обучения:
454
+ - Стоимость обучения, бел. руб.:
455
+
456
+ ### Строка 3
457
+ - N:
458
+ - ФИО работника:
459
+ - Должность работника:
460
+ - Название обучающего мероприятия, форума, конференции:
461
+ - Наименование обучающей организации:
462
+ - Сроки обучения:
463
+ - Стоимость обучения, бел. руб.:
464
+ Начальник сектора УЧР И.О.Фамилия
465
+
466
+ Справочно: данная информация направляется в УОП ЦРП по корпоративной ЭПОН не позднее 1-го числа месяца, следующего за отчетным месяцем.
467
+ ####
468
+ Вывод:
469
+ 1. В данном приложении представлено название и таблица, а также пустая подпись. Основная суть приложения в названии. Таблица пустая, значит это шаблон. Можно переписать пустые поля, которые участвуют в заполнении. Также в конце есть место для подписи. И справочная информация, которая является семантически значимой.
470
+ 2. [Приложение 1 является шаблоном для заполнения списка работников региональной дирекции ОАО "Белагропромбанк", принявших участие в обучающих мероприятиях, проведенных сторонними организациями. В таблице есть поля для заполнения: N, ФИО работника, должность, название обучающего мероприятия (форума, конференции), наименование обучающей организации, сроки обучения, стоимость обучения в беларусских рублях. В конце требуется подпись начальника сектора УЧР. Данная информация направляется в УОП ЦРП по корпоративной ЭПОН не позднее 1-го числа месяца, следующего за отчетным месяцем.]
471
+ ####
472
+ Пример 3
473
+ ####
474
+ [Источник] - Положение об обучении и развитии работников ОАО Белагропромбанк
475
+ Приложение 6
476
+ к Положению об обучении и
477
+ развитии работников
478
+ ОАО "Белагропромбанк"
479
+
480
+ ХАРАКТЕРИСТИКА
481
+
482
+ ####
483
+ Вывод:
484
+ 1. В данном приложении только заголовок "Характеристика". Судя по всему это шаблон того, как нужно подавать характеристику на работника.
485
+ 2. [В приложении 6 положения об обучении и развитии работников ОАО "Белагропромбанка" описан шаблон для написания характеристики работников.]
486
+ ####
487
+ Пример 4
488
+ ####
489
+ [Источник] - Положение об обучении и развитии работников ОАО Белагропромбанк
490
+ Приложение 2
491
+ к Положению об обучении и
492
+ развитии работников
493
+ ОАО "Белагропромбанк"
494
+ (в ред. Решения Правления ОАО "Белагропромбанк"
495
+ от 29.09.2023 N 73)
496
+
497
+ ДОКЛАДНАЯ ЗАПИСКА
498
+ __.__.20__ N__-__/__
499
+ г.________
500
+
501
+ О направлении на внутреннюю
502
+ стажировку
503
+
504
+ ####
505
+ Вывод:
506
+ 1. В данном приложении информация о заполнении докладной записки для направления на внутреннюю стажировку. Судя по всему это форма того, как нужно оформлять данную записку.
507
+ 2. [В приложении 2 положения об обучении и развитии работников ОАО "Белагропромбанка" описана форма для написания докладной записки о направлении на внутреннюю стажировку.]
508
+ ####
509
+ Пример 5
510
+ ####
511
+ [Источник] - Положение о банке ОАО Белагропромбанк
512
+ Приложение 9
513
+ ####
514
+ Вывод:
515
+ 1. В данном приложении отсутствует какая либо информация. Или вы неправильно подали мне данные. Я должен написать в скобка %%.
516
+ 2. [%%]
517
+ ####
518
+ Далее будет реальное приложение. Ты должен ответить только на реальное приложение.
519
+ ####
520
+ {replace_me}
521
+ ####
522
+ Вывод:
523
+ """
components/llm/utils.py CHANGED
@@ -12,21 +12,27 @@ def convert_to_openai_format(request: ChatRequest, system_prompt: str) -> List[D
12
  Returns:
13
  List[Dict[str, str]]: История в формате OpenAI [{'role': str, 'content': str}, ...].
14
  """
15
- # Добавляем системный промпт как первое сообщение
16
- openai_history = [{"role": "system", "content": system_prompt}]
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- content = system_prompt+"\n"
19
- # Преобразуем историю из ChatRequest
20
  for message in request.history:
21
- content += message.content
22
- if message.searchResults:
23
- search_results = "\n" + message.searchResults
24
- content += f"\n<search-results>\n{search_results}\n</search-results>"
25
 
26
- openai_history.append({
27
- "role": message.role,
28
- "content": content
29
- })
30
 
31
  return openai_history
32
 
 
12
  Returns:
13
  List[Dict[str, str]]: История в формате OpenAI [{'role': str, 'content': str}, ...].
14
  """
15
+ # # Добавляем системный промпт как первое сообщение
16
+ # openai_history = [{"role": "system", "content": system_prompt}]
17
+
18
+ # # Преобразуем историю из ChatRequest
19
+ # for message in request.history:
20
+ # content = message.content
21
+ # if message.searchResults:
22
+ # search_results = "\n" + message.searchResults
23
+ # content += f"\n<search-results>\n{search_results}\n</search-results>"
24
+
25
+ # openai_history.append({
26
+ # "role": message.role,
27
+ # "content": content
28
+ # })
29
 
30
+ user_prompt = system_prompt + "\n\n"
 
31
  for message in request.history:
32
+ content = message.content
33
+ user_prompt += content
 
 
34
 
35
+ openai_history = [{"role": "user", "content": user_prompt}]
 
 
 
36
 
37
  return openai_history
38
 
components/services/dataset.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import json
2
  import logging
3
  import os
@@ -5,11 +7,9 @@ import shutil
5
  import zipfile
6
  from datetime import datetime
7
  from pathlib import Path
8
- import asyncio
9
 
10
  import torch
11
  from fastapi import BackgroundTasks, HTTPException, UploadFile
12
- from components.dbo.models.entity import EntityModel
13
  from ntr_fileparser import ParsedDocument, UniversalParser
14
  from sqlalchemy.orm import Session
15
 
@@ -18,6 +18,7 @@ from common.configuration import Configuration
18
  from components.dbo.models.dataset import Dataset
19
  from components.dbo.models.dataset_document import DatasetDocument
20
  from components.dbo.models.document import Document
 
21
  from components.services.entity import EntityService
22
  from schemas.dataset import Dataset as DatasetSchema
23
  from schemas.dataset import DatasetExpanded as DatasetExpandedSchema
@@ -55,6 +56,20 @@ class DatasetService:
55
  self.entity_service = entity_service
56
  self.documents_path = Path(config.db_config.files.documents_path)
57
  self.tmp_path = Path(os.environ.get("APP_TMP_PATH", '.'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  logger.info("DatasetService initialized")
59
 
60
  def get_dataset(
@@ -210,11 +225,14 @@ class DatasetService:
210
  status_code=403, detail='Active dataset cannot be deleted'
211
  )
212
 
 
 
 
213
  session.query(EntityModel).filter(EntityModel.dataset_id == dataset_id).delete()
214
  session.delete(dataset)
215
  session.commit()
216
 
217
- def apply_draft_task(self, dataset_id: int):
218
  """
219
  Метод для выполнения в отдельном процессе.
220
  """
@@ -233,6 +251,7 @@ class DatasetService:
233
  active_dataset = (
234
  session.query(Dataset).filter(Dataset.is_active == True).first()
235
  )
 
236
 
237
  self.apply_draft(dataset)
238
  dataset.is_draft = False
@@ -241,12 +260,17 @@ class DatasetService:
241
  active_dataset.is_active = False
242
 
243
  session.commit()
 
 
 
 
 
244
  logger.info(f"apply_draft_task finished")
245
  except Exception as e:
246
  logger.error(f"Error applying draft: {e}")
247
  raise
248
 
249
- def activate_dataset(
250
  self, dataset_id: int, background_tasks: BackgroundTasks
251
  ) -> DatasetExpandedSchema:
252
  """
@@ -266,15 +290,22 @@ class DatasetService:
266
  raise HTTPException(status_code=400, detail='Dataset is already active')
267
 
268
  if dataset.is_draft:
269
- background_tasks.add_task(self.apply_draft_task, dataset_id)
 
270
  else:
271
- dataset.is_active = True
272
 
 
273
  if active_dataset:
274
  active_dataset.is_active = False
275
-
276
  session.commit()
277
 
 
 
 
 
 
 
278
  return self.get_dataset(dataset_id)
279
 
280
  def get_processing(self) -> DatasetProcessing:
 
1
+ import asyncio
2
+ from functools import partial
3
  import json
4
  import logging
5
  import os
 
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
14
  from sqlalchemy.orm import Session
15
 
 
18
  from components.dbo.models.dataset import Dataset
19
  from components.dbo.models.dataset_document import DatasetDocument
20
  from components.dbo.models.document import Document
21
+ from components.dbo.models.entity import EntityModel
22
  from components.services.entity import EntityService
23
  from schemas.dataset import Dataset as DatasetSchema
24
  from schemas.dataset import DatasetExpanded as DatasetExpandedSchema
 
56
  self.entity_service = entity_service
57
  self.documents_path = Path(config.db_config.files.documents_path)
58
  self.tmp_path = Path(os.environ.get("APP_TMP_PATH", '.'))
59
+
60
+ # Начальная загрузка кеша для активного датасета
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
 
75
  def get_dataset(
 
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
 
235
+ async def apply_draft_task(self, dataset_id: int):
236
  """
237
  Метод для выполнения в отдельном процессе.
238
  """
 
251
  active_dataset = (
252
  session.query(Dataset).filter(Dataset.is_active == True).first()
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
 
260
  active_dataset.is_active = False
261
 
262
  session.commit()
263
+
264
+ # Обновляем кеши после применения черновика
265
+ if old_active_dataset_id:
266
+ self.entity_service.invalidate_cache()
267
+ await self.entity_service.build_or_rebuild_cache_async(dataset_id)
268
  logger.info(f"apply_draft_task finished")
269
  except Exception as e:
270
  logger.error(f"Error applying draft: {e}")
271
  raise
272
 
273
+ async def activate_dataset(
274
  self, dataset_id: int, background_tasks: BackgroundTasks
275
  ) -> DatasetExpandedSchema:
276
  """
 
290
  raise HTTPException(status_code=400, detail='Dataset is already active')
291
 
292
  if dataset.is_draft:
293
+ wrapper = partial(asyncio.run, self.apply_draft_task(dataset_id))
294
+ background_tasks.add_task(wrapper)
295
  else:
296
+ old_active_dataset_id = active_dataset.id if active_dataset else None
297
 
298
+ dataset.is_active = True
299
  if active_dataset:
300
  active_dataset.is_active = False
 
301
  session.commit()
302
 
303
+ # Обновляем кеши после коммита
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
 
311
  def get_processing(self) -> DatasetProcessing:
components/services/dialogue.py CHANGED
@@ -1,16 +1,12 @@
1
  import logging
2
- import os
3
  import re
4
- from typing import List, Optional, Tuple
5
 
6
  from pydantic import BaseModel
7
 
8
- from common.configuration import Configuration
9
- from components.llm.common import ChatRequest, LlmParams, LlmPredictParams, Message
10
  from components.llm.deepinfra_api import DeepInfraApi
11
  from components.llm.prompts import PROMPT_QE
12
- from components.services.dataset import DatasetService
13
- from components.services.entity import EntityService
14
  from components.services.llm_config import LLMConfigService
15
 
16
  logger = logging.getLogger(__name__)
@@ -25,15 +21,10 @@ class QEResult(BaseModel):
25
  class DialogueService:
26
  def __init__(
27
  self,
28
- config: Configuration,
29
- entity_service: EntityService,
30
- dataset_service: DatasetService,
31
  llm_api: DeepInfraApi,
32
  llm_config_service: LLMConfigService,
33
  ) -> None:
34
  self.prompt = PROMPT_QE
35
- self.entity_service = entity_service
36
- self.dataset_service = dataset_service
37
  self.llm_api = llm_api
38
 
39
  p = llm_config_service.get_default()
@@ -50,7 +41,7 @@ class DialogueService:
50
  async def get_qe_result(self, history: List[Message]) -> QEResult:
51
  """
52
  Получает результат QE.
53
-
54
  Args:
55
  history: История диалога в виде списка сообщений
56
 
@@ -72,9 +63,9 @@ class DialogueService:
72
  return QEResult(
73
  use_search=from_chat is not None,
74
  search_query=from_chat.content if from_chat else None,
75
- debug_message=response
76
  )
77
-
78
  def get_qe_result_from_chat(self, history: List[Message]) -> QEResult:
79
  from_chat = self._get_search_query(history)
80
  return QEResult(
@@ -131,8 +122,9 @@ class DialogueService:
131
  else:
132
  raise ValueError("Первая часть текста должна содержать 'ДА' или 'НЕТ'.")
133
 
134
- return QEResult(use_search=bool_var, search_query=second_part,
135
- debug_message=input_text)
 
136
 
137
  def _get_search_query(self, history: List[Message]) -> Message | None:
138
  """
 
1
  import logging
 
2
  import re
3
+ from typing import List, Optional
4
 
5
  from pydantic import BaseModel
6
 
7
+ from components.llm.common import ChatRequest, LlmPredictParams, Message
 
8
  from components.llm.deepinfra_api import DeepInfraApi
9
  from components.llm.prompts import PROMPT_QE
 
 
10
  from components.services.llm_config import LLMConfigService
11
 
12
  logger = logging.getLogger(__name__)
 
21
  class DialogueService:
22
  def __init__(
23
  self,
 
 
 
24
  llm_api: DeepInfraApi,
25
  llm_config_service: LLMConfigService,
26
  ) -> None:
27
  self.prompt = PROMPT_QE
 
 
28
  self.llm_api = llm_api
29
 
30
  p = llm_config_service.get_default()
 
41
  async def get_qe_result(self, history: List[Message]) -> QEResult:
42
  """
43
  Получает результат QE.
44
+
45
  Args:
46
  history: История диалога в виде списка сообщений
47
 
 
63
  return QEResult(
64
  use_search=from_chat is not None,
65
  search_query=from_chat.content if from_chat else None,
66
+ debug_message=response,
67
  )
68
+
69
  def get_qe_result_from_chat(self, history: List[Message]) -> QEResult:
70
  from_chat = self._get_search_query(history)
71
  return QEResult(
 
122
  else:
123
  raise ValueError("Первая часть текста должна содержать 'ДА' или 'НЕТ'.")
124
 
125
+ return QEResult(
126
+ use_search=bool_var, search_query=second_part, debug_message=input_text
127
+ )
128
 
129
  def _get_search_query(self, history: List[Message]) -> Message | None:
130
  """
components/services/entity.py CHANGED
@@ -4,7 +4,8 @@ from uuid import UUID
4
 
5
  import numpy as np
6
  from ntr_fileparser import ParsedDocument
7
- from ntr_text_fragmentation import EntitiesExtractor, InjectionBuilder
 
8
 
9
  from common.configuration import Configuration
10
  from components.dbo.chunk_repository import ChunkRepository
@@ -69,6 +70,61 @@ class EntityService:
69
  process_tables=False,
70
  )
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  def _ensure_faiss_initialized(self, dataset_id: int) -> None:
73
  """
74
  Проверяет и при необходимости инициализирует или обновляет FAISS индекс.
@@ -76,7 +132,7 @@ class EntityService:
76
  Args:
77
  dataset_id: ID датасета для инициализации
78
  """
79
- # Если индекс не инициализирован или датасет изменился
80
  if self.faiss_search is None or self.current_dataset_id != dataset_id:
81
  logger.info(f'Initializing FAISS for dataset {dataset_id}')
82
  entities, embeddings = self.chunk_repository.get_searching_entities(
@@ -124,6 +180,7 @@ class EntityService:
124
  """
125
  logger.info(f"Processing document {document.name} for dataset {dataset_id}")
126
 
 
127
  if 'Приложение' in document.name:
128
  entities = await self.appendices_extractor.extract_async(document)
129
  else:
@@ -136,46 +193,73 @@ class EntityService:
136
  filtering_texts = [entity.in_search_text for entity in filtering_entities]
137
 
138
  embeddings = self.vectorizer.vectorize(filtering_texts, progress_callback)
139
- embeddings_dict = {
140
- str(entity.id): embedding
141
- for entity, embedding in zip(filtering_entities, embeddings)
142
- }
 
 
 
 
 
 
 
143
 
144
  # Сохраняем в базу
145
- self.chunk_repository.add_entities(entities, dataset_id, embeddings_dict)
146
 
147
  logger.info(f"Added {len(entities)} entities to dataset {dataset_id}")
148
 
149
- def build_text(
150
  self,
151
  entities: list[str],
 
152
  chunk_scores: Optional[list[float]] = None,
153
  include_tables: bool = True,
154
  max_documents: Optional[int] = None,
155
  ) -> str:
156
  """
157
- Сборка текста из сущностей.
158
 
159
  Args:
160
- entities: Список идентификаторов сущностей
161
- chunk_scores: Список весов чанков
 
162
  include_tables: Флаг включения таблиц
163
  max_documents: Максимальное количество документов
164
 
165
  Returns:
166
  Собранный текст
167
  """
168
- entities = [UUID(entity) for entity in entities]
169
- entities = self.chunk_repository.get_entities_by_ids(entities)
170
- logger.info(f"Building text for {len(entities)} entities")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  if chunk_scores is not None:
172
- chunk_scores = {
173
- entity.id: score for entity, score in zip(entities, chunk_scores)
174
- }
175
- builder = InjectionBuilder(self.chunk_repository)
176
- return builder.build(
177
- entities,
178
- scores=chunk_scores,
 
 
 
 
179
  include_tables=include_tables,
180
  neighbors_max_distance=self.neighbors_max_distance,
181
  max_documents=max_documents,
@@ -185,6 +269,7 @@ class EntityService:
185
  self,
186
  query: str,
187
  dataset_id: int,
 
188
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
189
  """
190
  Поиск похожих сущностей.
@@ -192,6 +277,7 @@ class EntityService:
192
  Args:
193
  query: Текст запроса
194
  dataset_id: ID датасета
 
195
 
196
  Returns:
197
  tuple[np.ndarray, np.ndarray, np.ndarray]:
@@ -199,14 +285,19 @@ class EntityService:
199
  - Оценки сходства
200
  - Идентификаторы найденных сущностей
201
  """
202
- # Убеждаемся, что FAISS инициализирован для текущего датасета
203
  self._ensure_faiss_initialized(dataset_id)
204
 
205
  if self.faiss_search is None:
 
 
 
206
  return np.array([]), np.array([]), np.array([])
207
 
208
- # Выполняем поиск
209
- return self.faiss_search.search_vectors(query)
 
 
210
 
211
  def search_similar(
212
  self,
@@ -225,7 +316,7 @@ class EntityService:
225
  Returns:
226
  tuple[list[list[str]], list[str], list[float]]:
227
  - Перефильтрованный список идентификаторов сущностей из прошлых запросов
228
- - Список идентификаторов найденных сущностей
229
  - Скоры найденных сущностей
230
  """
231
  self._ensure_faiss_initialized(dataset_id)
 
4
 
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
 
70
  process_tables=False,
71
  )
72
 
73
+ self._in_memory_cache: InMemoryEntityRepository = None
74
+ self._cached_dataset_id: int | None = None
75
+
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 кеш уже пуст. Ничего не делаем.")
83
+
84
+ def build_cache(self, dataset_id: int) -> None:
85
+ """Строит кеш для указанного датасета."""
86
+ all_entities = self.chunk_repository.get_all_entities_for_dataset(dataset_id)
87
+ in_memory_repo = InMemoryEntityRepository(entities=all_entities)
88
+ self._in_memory_cache = in_memory_repo
89
+ self._cached_dataset_id = dataset_id
90
+
91
+ async def build_or_rebuild_cache_async(self, dataset_id: int) -> None:
92
+ """
93
+ Строит или перестраивает кеш для указанного датасета, удаляя предыдущий кеш.
94
+ """
95
+
96
+ all_entities = await self.chunk_repository.get_all_entities_for_dataset_async(dataset_id)
97
+ if not all_entities:
98
+ logger.warning(f"No entities found for dataset {dataset_id}. Cache not built.")
99
+ self._in_memory_cache = None
100
+ self._cached_dataset_id = None
101
+ return
102
+
103
+ logger.info(f"Building new in-memory cache for dataset {dataset_id}")
104
+ in_memory_repo = InMemoryEntityRepository(entities=all_entities)
105
+ self._in_memory_cache = in_memory_repo
106
+ self._cached_dataset_id = dataset_id
107
+ logger.info(f"Cached {len(all_entities)} entities for dataset {dataset_id}")
108
+
109
+ def _get_repository_for_dataset(self, dataset_id: int) -> EntityRepository:
110
+ """
111
+ Возвращает кешированный репозиторий, если он существует и соответствует
112
+ запрошенному dataset_id, иначе возвращает основной репозиторий ChunkRepository.
113
+ """
114
+ # Проверяем совпадение ID с закешированным
115
+ if self._cached_dataset_id == dataset_id and self._in_memory_cache is not None:
116
+ return self._in_memory_cache
117
+ else:
118
+ # Логируем причину промаха кеша для диагностики
119
+ if not self._in_memory_cache:
120
+ logger.warning(f"Cache miss for dataset {dataset_id}: Cache is empty. Using ChunkRepository (DB).")
121
+ elif self._cached_dataset_id != dataset_id:
122
+ logger.warning(f"Cache miss for dataset {dataset_id}: Cache contains data for dataset {self._cached_dataset_id}. Using ChunkRepository (DB).")
123
+ else: # На случай непредвиденной ситуации
124
+ logger.warning(f"Cache miss for dataset {dataset_id}: Unknown reason. Using ChunkRepository (DB).")
125
+
126
+ return self.chunk_repository
127
+
128
  def _ensure_faiss_initialized(self, dataset_id: int) -> None:
129
  """
130
  Проверяет и при необходимости инициализирует или обновляет FAISS индекс.
 
132
  Args:
133
  dataset_id: ID датасета для инициализации
134
  """
135
+ # Переинициализируем FAISS, только если ID датасета изменился
136
  if self.faiss_search is None or self.current_dataset_id != dataset_id:
137
  logger.info(f'Initializing FAISS for dataset {dataset_id}')
138
  entities, embeddings = self.chunk_repository.get_searching_entities(
 
180
  """
181
  logger.info(f"Processing document {document.name} for dataset {dataset_id}")
182
 
183
+ # Определяем экстрактор в зависимости от имени документа
184
  if 'Приложение' in document.name:
185
  entities = await self.appendices_extractor.extract_async(document)
186
  else:
 
193
  filtering_texts = [entity.in_search_text for entity in filtering_entities]
194
 
195
  embeddings = self.vectorizer.vectorize(filtering_texts, progress_callback)
196
+
197
+ # Собираем словарь эмбеддингов только для найденных сущностей
198
+ embeddings_dict = {}
199
+ if embeddings is not None:
200
+ embeddings_dict = {
201
+ str(entity.id): embedding
202
+ for entity, embedding in zip(filtering_entities, embeddings)
203
+ if embedding is not None
204
+ }
205
+ else:
206
+ logger.warning(f"Vectorizer returned None for document {document.name}")
207
 
208
  # Сохраняем в базу
209
+ await self.chunk_repository.add_entities_async(entities, dataset_id, embeddings_dict)
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],
216
+ dataset_id: int,
217
  chunk_scores: Optional[list[float]] = None,
218
  include_tables: bool = True,
219
  max_documents: Optional[int] = None,
220
  ) -> str:
221
  """
222
+ Асинхронная сборка текста из сущностей с использованием кешированного или основного репозитория.
223
 
224
  Args:
225
+ entities: Список идентификаторов сущностей (строки UUID)
226
+ dataset_id: ID датасета для получения репозитория (кешированного или БД)
227
+ chunk_scores: Список весов чанков (соответствует порядку entities)
228
  include_tables: Флаг включения таблиц
229
  max_documents: Максимальное количество документов
230
 
231
  Returns:
232
  Собранный текст
233
  """
234
+ if not entities:
235
+ logger.warning("build_text called with empty entities list.")
236
+ return ""
237
+
238
+ try:
239
+ entity_ids = [UUID(entity) for entity in entities]
240
+ except ValueError as e:
241
+ logger.error(f"Invalid UUID format found in entities list: {e}")
242
+ raise ValueError(f"Invalid UUID format in entities list: {entities}") from e
243
+
244
+ repository = self._get_repository_for_dataset(dataset_id)
245
+
246
+ # Передаем репозиторий (кеш или БД) в InjectionBuilder
247
+ builder = InjectionBuilder(repository=repository)
248
+
249
+ # Создаем словарь score_map UUID -> score, если chunk_scores предоставлены
250
+ scores_map: dict[UUID, float] | None = None
251
  if chunk_scores is not None:
252
+ if len(entity_ids) == len(chunk_scores):
253
+ scores_map = {eid: score for eid, score in zip(entity_ids, chunk_scores)}
254
+ else:
255
+ logger.warning(f"Length mismatch between entities ({len(entity_ids)}) and chunk_scores ({len(chunk_scores)}). Scores ignored.")
256
+
257
+ logger.info(f"Building text for {len(entity_ids)} entities from dataset {dataset_id} using {repository.__class__.__name__}")
258
+
259
+ # Вызываем асинхронный метод сборщика
260
+ return await builder.build_async(
261
+ entities=entity_ids, # Передаем список UUID
262
+ scores=scores_map, # Передаем словарь UUID -> score
263
  include_tables=include_tables,
264
  neighbors_max_distance=self.neighbors_max_distance,
265
  max_documents=max_documents,
 
269
  self,
270
  query: str,
271
  dataset_id: int,
272
+ k: int | None = None,
273
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
274
  """
275
  Поиск похожих сущностей.
 
277
  Args:
278
  query: Текст запроса
279
  dataset_id: ID датасета
280
+ k: Максимальное количество возвращаемых результатов (по умолчанию - все).
281
 
282
  Returns:
283
  tuple[np.ndarray, np.ndarray, np.ndarray]:
 
285
  - Оценки сходства
286
  - Идентификаторы найденных сущностей
287
  """
288
+ logger.info(f"Searching similar entities for dataset {dataset_id} with k={k}")
289
  self._ensure_faiss_initialized(dataset_id)
290
 
291
  if self.faiss_search is None:
292
+ logger.warning(
293
+ f"FAISS search not initialized for dataset {dataset_id}. Returning empty results."
294
+ )
295
  return np.array([]), np.array([]), np.array([])
296
 
297
+ # Выполняем поиск с использованием параметра k
298
+ query_vector, scores, ids = self.faiss_search.search_vectors(query, max_entities=k)
299
+ logger.info(f"Found {len(ids)} similar entities.")
300
+ return query_vector, scores, ids
301
 
302
  def search_similar(
303
  self,
 
316
  Returns:
317
  tuple[list[list[str]], list[str], list[float]]:
318
  - Перефильтрованный список идентификаторов сущностей из прошлых запросов
319
+ - Список идентификаторов найденных сущностей (строки UUID)
320
  - Скоры найденных сущностей
321
  """
322
  self._ensure_faiss_initialized(dataset_id)
components/services/search_metrics.py CHANGED
@@ -357,7 +357,8 @@ class SearchMetricsService:
357
  # +++ Получаем тексты чанков для расчета метрик chunk/punct +++
358
  retrieved_chunks_texts_for_n = []
359
  if chunk_ids_for_n.size > 0:
360
- chunks_for_n = self.entity_service.chunk_repository.get_entities_by_ids(
 
361
  [UUID(ch_id) for ch_id in chunk_ids_for_n]
362
  )
363
  chunk_map_for_n = {str(ch.id): ch for ch in chunks_for_n}
@@ -392,8 +393,13 @@ class SearchMetricsService:
392
  # --- Метрики Сборки ---
393
  # +++ Правильная сборка контекста с помощью build_text +++
394
  logger.info(f"Building context for QID={question_id}, n={n} using {len(chunk_ids_for_n)} chunk IDs...")
395
- assembled_context_for_n = self.entity_service.build_text(
396
- entities=chunk_ids_for_n # Передаем список ID строк
 
 
 
 
 
397
  )
398
 
399
  assembly_recall, single_q_assembly_found, single_q_valid_gt = self._calculate_assembly_punct_recall(
 
357
  # +++ Получаем тексты чанков для расчета метрик chunk/punct +++
358
  retrieved_chunks_texts_for_n = []
359
  if chunk_ids_for_n.size > 0:
360
+ # Используем асинхронный вызов
361
+ chunks_for_n = await self.entity_service.chunk_repository.get_entities_by_ids_async(
362
  [UUID(ch_id) for ch_id in chunk_ids_for_n]
363
  )
364
  chunk_map_for_n = {str(ch.id): ch for ch in chunks_for_n}
 
393
  # --- Метрики Сборки ---
394
  # +++ Правильная сборка контекста с помощью build_text +++
395
  logger.info(f"Building context for QID={question_id}, n={n} using {len(chunk_ids_for_n)} chunk IDs...")
396
+ # Используем асинхронный вызов и передаем dataset_id
397
+ assembled_context_for_n = await self.entity_service.build_text_async(
398
+ entities=chunk_ids_for_n.tolist(), # Преобразуем numpy array в list[str]
399
+ dataset_id=dataset_id, # Передаем ID датасета
400
+ # chunk_scores можно передать, если они нужны для сборки, иначе None
401
+ # include_tables=True, # По умолчанию
402
+ # max_documents=None, # По умолчанию
403
  )
404
 
405
  assembly_recall, single_q_assembly_found, single_q_valid_gt = self._calculate_assembly_punct_recall(
config_dev.yaml CHANGED
@@ -18,8 +18,8 @@ bd:
18
  use_vector_search: true
19
  vectorizer_path: !ENV ${EMBEDDING_MODEL_PATH:BAAI/bge-m3}
20
  device: !ENV ${DEVICE:cuda}
21
- max_entities_per_message: 75
22
- max_entities_per_dialogue: 500
23
 
24
  files:
25
  empty_start: true
 
18
  use_vector_search: true
19
  vectorizer_path: !ENV ${EMBEDDING_MODEL_PATH:BAAI/bge-m3}
20
  device: !ENV ${DEVICE:cuda}
21
+ max_entities_per_message: 150
22
+ max_entities_per_dialogue: 300
23
 
24
  files:
25
  empty_start: true
lib/extractor/ntr_text_fragmentation/additors/tables/table_processor.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from ntr_fileparser import ParsedRow, ParsedSubtable, ParsedTable
2
 
3
  from ...models import LinkerEntity
@@ -5,6 +6,9 @@ from ...repositories.entity_repository import EntityRepository, GroupedEntities
5
  from .models import SubTableEntity, TableEntity, TableRowEntity
6
 
7
 
 
 
 
8
  class TableProcessor:
9
  def __init__(self):
10
  pass
@@ -114,6 +118,84 @@ class TableProcessor:
114
  entity.owner_id = subtable_entity.id
115
  return entity
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def build(
118
  self,
119
  repository: EntityRepository,
@@ -157,7 +239,7 @@ class TableProcessor:
157
  row,
158
  subtable_header or table_header,
159
  )
160
-
161
  if table.note:
162
  result += f"**Примечание:** {table.note}\n"
163
 
@@ -173,6 +255,13 @@ class TableProcessor:
173
  cells = "\n".join([f"- - {cell}" for cell in row.cells])
174
  else:
175
  normalized_header = [h.replace('\n', '') for h in header]
176
- cells = "\n".join([f" - **{normalized_header[i]}**: {row.cells[i]}".replace('\n', '\n -') for i in range(len(header))])
 
 
 
 
 
 
 
177
 
178
  return f"- {row_name}\n{cells}\n"
 
1
+ import logging
2
  from ntr_fileparser import ParsedRow, ParsedSubtable, ParsedTable
3
 
4
  from ...models import LinkerEntity
 
6
  from .models import SubTableEntity, TableEntity, TableRowEntity
7
 
8
 
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
  class TableProcessor:
13
  def __init__(self):
14
  pass
 
118
  entity.owner_id = subtable_entity.id
119
  return entity
120
 
121
+ async def build_async(
122
+ self,
123
+ repository: EntityRepository,
124
+ group: GroupedEntities[TableEntity],
125
+ ) -> str | None:
126
+ """
127
+ Асинхронно собирает текст таблицы из группы сущностей.
128
+ """
129
+ if not group or not group.composer:
130
+ return None
131
+
132
+ table = group.composer
133
+ entities = group.entities
134
+
135
+ if not isinstance(table, TableEntity):
136
+ logger.warning(f"Ожидался TableEntity в composer, получен {type(table)}.")
137
+ return None
138
+
139
+ # Асинхронно группируем строки по подтаблицам
140
+ subtable_grouped: list[GroupedEntities[SubTableEntity]] = (
141
+ await repository.group_entities_hierarchically_async(
142
+ entities=entities,
143
+ root_type=SubTableEntity,
144
+ sort=True, # Важно для порядка подтаблиц и строк внутри
145
+ )
146
+ )
147
+
148
+ if not subtable_grouped:
149
+ logger.debug(f"Нет подтаблиц для таблицы '{table.name}' ({table.id})")
150
+ # Можно вернуть только заголовок и примечание, если они есть
151
+ # return f"#### {table.title or f'Таблица {table.number_in_relation}'}\n{f'**Примечание:** {table.note}\n' if table.note else ''}".strip()
152
+ return None # Или ничего не возвращать, если нет строк
153
+
154
+ result_parts = []
155
+
156
+ # Заголовок таблицы
157
+ if table.title:
158
+ result_parts.append(f"#### {table.title}")
159
+ else:
160
+ result_parts.append(f"#### Таблица {table.number_in_relation}")
161
+
162
+ table_header = table.header # Синхронное получение атрибута
163
+
164
+ # Обработка каждой подтаблицы (синхронно внутри, т.к. CPU-bound)
165
+ for subtable_group in subtable_grouped:
166
+ if not subtable_group or not subtable_group.composer:
167
+ continue
168
+
169
+ subtable = subtable_group.composer
170
+ if not isinstance(subtable, SubTableEntity):
171
+ continue
172
+
173
+ subtable_header = subtable.header # Синхронно
174
+ # Фильтруем только строки таблицы, сортировка уже выполнена group_entities_hierarchically_async
175
+ rows = [
176
+ row
177
+ for row in subtable_group.entities
178
+ if isinstance(row, TableRowEntity)
179
+ ]
180
+
181
+ if subtable.title:
182
+ result_parts.append(f"##### {subtable.title}")
183
+
184
+ for row in rows:
185
+ # _prepare_row - чисто CPU-bound операция
186
+ result_parts.append(
187
+ self._prepare_row(
188
+ row,
189
+ subtable_header or table_header,
190
+ )
191
+ )
192
+
193
+ # Примечание к таблице
194
+ if table.note:
195
+ result_parts.append(f"**Примечание:** {table.note}")
196
+
197
+ return "\n".join(result_parts)
198
+
199
  def build(
200
  self,
201
  repository: EntityRepository,
 
239
  row,
240
  subtable_header or table_header,
241
  )
242
+
243
  if table.note:
244
  result += f"**Примечание:** {table.note}\n"
245
 
 
255
  cells = "\n".join([f"- - {cell}" for cell in row.cells])
256
  else:
257
  normalized_header = [h.replace('\n', '') for h in header]
258
+ cells = "\n".join(
259
+ [
260
+ f" - **{normalized_header[i]}**: {row.cells[i]}".replace(
261
+ '\n', '\n -'
262
+ )
263
+ for i in range(len(header))
264
+ ]
265
+ )
266
 
267
  return f"- {row_name}\n{cells}\n"
lib/extractor/ntr_text_fragmentation/additors/tables_processor.py CHANGED
@@ -2,11 +2,13 @@
2
  Процессор таблиц из документа.
3
  """
4
 
 
 
5
  from ntr_fileparser import ParsedDocument
6
 
7
  from ..models import LinkerEntity
8
- from .tables import TableProcessor, TableEntity
9
  from ..repositories import EntityRepository, GroupedEntities
 
10
 
11
 
12
  class TablesProcessor:
@@ -29,6 +31,41 @@ class TablesProcessor:
29
  entities.extend(self.table_processor.extract(table, doc_entity))
30
  return entities
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def build(
33
  self,
34
  repository: EntityRepository,
@@ -51,7 +88,7 @@ class TablesProcessor:
51
  )
52
 
53
  result = "\n\n".join(
54
- self.table_processor.build(repository, group) for group in groups
55
  )
56
 
57
  return result
 
2
  Процессор таблиц из документа.
3
  """
4
 
5
+ import asyncio
6
+
7
  from ntr_fileparser import ParsedDocument
8
 
9
  from ..models import LinkerEntity
 
10
  from ..repositories import EntityRepository, GroupedEntities
11
+ from .tables import TableEntity, TableProcessor
12
 
13
 
14
  class TablesProcessor:
 
31
  entities.extend(self.table_processor.extract(table, doc_entity))
32
  return entities
33
 
34
+ async def build_async(
35
+ self,
36
+ repository: EntityRepository,
37
+ entities: list[LinkerEntity],
38
+ ) -> str:
39
+ """
40
+ Асинхронно собирает текст таблиц из списка сущностей.
41
+ """
42
+ if not entities:
43
+ return ""
44
+
45
+ # Асинхронно группируем сущности по TableEntity
46
+ groups: list[GroupedEntities[TableEntity]] = (
47
+ await repository.group_entities_hierarchically_async(
48
+ entities=entities,
49
+ root_type=TableEntity,
50
+ sort=True,
51
+ )
52
+ )
53
+
54
+ if not groups:
55
+ return ""
56
+
57
+ groups = sorted(
58
+ groups, key=lambda x: x.composer.number_in_relation if x.composer else float('inf'),
59
+ )
60
+
61
+ build_tasks = [
62
+ self.table_processor.build_async(repository, group)
63
+ for group in groups
64
+ ]
65
+ results = await asyncio.gather(*build_tasks)
66
+
67
+ return "\n\n".join(filter(None, results))
68
+
69
  def build(
70
  self,
71
  repository: EntityRepository,
 
88
  )
89
 
90
  result = "\n\n".join(
91
+ filter(None, (self.table_processor.build(repository, group) for group in groups))
92
  )
93
 
94
  return result
lib/extractor/ntr_text_fragmentation/chunking/chunking_strategy.py CHANGED
@@ -2,6 +2,7 @@
2
  Абстрактный базовый класс для стратегий чанкинга.
3
  """
4
 
 
5
  import logging
6
  from abc import ABC, abstractmethod
7
 
@@ -102,6 +103,18 @@ class ChunkingStrategy(ABC):
102
 
103
  return result.strip()
104
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  @classmethod
106
  def _build_sequenced_chunks(
107
  cls,
 
2
  Абстрактный базовый класс для стратегий чанкинга.
3
  """
4
 
5
+ import asyncio
6
  import logging
7
  from abc import ABC, abstractmethod
8
 
 
103
 
104
  return result.strip()
105
 
106
+ @classmethod
107
+ async def dechunk_async(
108
+ cls,
109
+ repository: EntityRepository,
110
+ filtered_entities: list[LinkerEntity],
111
+ ) -> str:
112
+ """
113
+ Асинхронно собирает текст из отфильтрованных чанков к одному документу.
114
+ По умолчанию вызывает синхронную версию.
115
+ """
116
+ return await asyncio.to_thread(cls.dechunk, repository, filtered_entities)
117
+
118
  @classmethod
119
  def _build_sequenced_chunks(
120
  cls,
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/__init__.py CHANGED
@@ -2,14 +2,33 @@
2
  Модуль содержащий конкретные стратегии для чанкинга текста.
3
  """
4
 
 
 
 
5
  from .fixed_size import FixedSizeChunk
6
- from .fixed_size_chunking import (
7
- FixedSizeChunkingStrategy,
8
- FIXED_SIZE,
9
- )
10
 
11
  __all__ = [
12
  "FixedSizeChunk",
13
  "FixedSizeChunkingStrategy",
14
  "FIXED_SIZE",
 
 
 
 
 
 
 
 
 
 
15
  ]
 
 
 
 
 
 
 
 
2
  Модуль содержащий конкретные стратегии для чанкинга текста.
3
  """
4
 
5
+ # Импортируем конкретные сущности из BLM
6
+ from .blm import (BLM_PARAGRAPH, BLM_SENTENCE, BlmChunk,
7
+ BlmParagraphChunkingStrategy, BlmSentenceChunkingStrategy)
8
  from .fixed_size import FixedSizeChunk
9
+ from .fixed_size_chunking import FIXED_SIZE, FixedSizeChunkingStrategy
10
+ from .paragraph_chunking import PARAGRAPH, ParagraphChunkingStrategy
11
+ from .sentence_chunking import SENTENCE, SentenceChunkingStrategy
 
12
 
13
  __all__ = [
14
  "FixedSizeChunk",
15
  "FixedSizeChunkingStrategy",
16
  "FIXED_SIZE",
17
+ "ParagraphChunkingStrategy",
18
+ "PARAGRAPH",
19
+ "SentenceChunkingStrategy",
20
+ "SENTENCE",
21
+ # Явно добавляем BLM экспорты
22
+ "BlmChunk",
23
+ "BlmParagraphChunkingStrategy",
24
+ "BLM_PARAGRAPH",
25
+ "BlmSentenceChunkingStrategy",
26
+ "BLM_SENTENCE",
27
  ]
28
+
29
+ # Динамическое добавление больше не нужно
30
+ # import inspect
31
+ # import sys
32
+ # from . import blm
33
+ # blm_exports = [name for name, obj in inspect.getmembers(blm) if not name.startswith("_")]
34
+ # __all__.extend(blm_exports)
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BLM-специфичные стратегии и модели чанкинга.
3
+ """
4
+
5
+ from .blm_chunk import BlmChunk
6
+ from .blm_paragraph_chunking import BLM_PARAGRAPH, BlmParagraphChunkingStrategy
7
+ from .blm_sentence_chunking import BLM_SENTENCE, BlmSentenceChunkingStrategy
8
+
9
+ # Утилиты не экспортируем вовне
10
+ # from .blm_utils import ...
11
+
12
+ __all__ = [
13
+ "BlmChunk",
14
+ "BlmParagraphChunkingStrategy",
15
+ "BLM_PARAGRAPH",
16
+ "BlmSentenceChunkingStrategy",
17
+ "BLM_SENTENCE",
18
+ ]
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_chunk.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Класс для представления чанка BLM-документа со ссылками на сноски.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from ....models import Entity, LinkerEntity, register_entity
8
+ from ...models.chunk import Chunk
9
+
10
+
11
+ @register_entity
12
+ @dataclass
13
+ class BlmChunk(Chunk):
14
+ """
15
+ Представляет чанк документа в рамках BLM-специфичной обработки.
16
+
17
+ Расширяет базовый класс Chunk полем для хранения идентификаторов
18
+ сносок, на которые ссылается текст этого чанка.
19
+
20
+ Attributes:
21
+ referenced_footnote_ids (list[int]): Список числовых идентификаторов сносок,
22
+ найденных в исходном тексте этого чанка.
23
+ """
24
+
25
+ referenced_footnote_ids: list[int] = field(default_factory=list)
26
+
27
+ @classmethod
28
+ def _deserialize_to_me(cls, data: Entity) -> "BlmChunk":
29
+ """
30
+ Десериализует BlmChunk из объекта Entity (LinkerEntity).
31
+
32
+ Извлекает поле `referenced_footnote_ids` из метаданных (`_referenced_footnote_ids`).
33
+
34
+ Args:
35
+ data: Объект Entity (LinkerEntity) для десериализации.
36
+
37
+ Returns:
38
+ Новый экземпляр BlmChunk с данными из Entity.
39
+
40
+ Raises:
41
+ TypeError: Если data не является экземпляром LinkerEntity или его подкласса.
42
+ """
43
+ if not isinstance(data, LinkerEntity):
44
+ raise TypeError(
45
+ f"Ожидался LinkerEntity или его подкласс, получен {type(data)}"
46
+ )
47
+
48
+ metadata = data.metadata or {}
49
+
50
+ ref_ids = list(metadata.get("_referenced_footnote_ids", []))
51
+
52
+ clean_metadata = {k: v for k, v in metadata.items() if not k.startswith('_')}
53
+
54
+ return cls(
55
+ id=data.id,
56
+ name=data.name,
57
+ text=data.text,
58
+ in_search_text=data.in_search_text,
59
+ metadata=clean_metadata,
60
+ source_id=data.source_id,
61
+ target_id=data.target_id,
62
+ number_in_relation=data.number_in_relation,
63
+ groupper=data.groupper,
64
+ type=cls.__name__,
65
+ referenced_footnote_ids=ref_ids,
66
+ )
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_paragraph_chunking.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BLM-специфичная стратегия чанкинга по абзацам.
3
+ """
4
+
5
+ import logging
6
+ from uuid import uuid4
7
+
8
+ from ntr_fileparser import ParsedDocument
9
+
10
+ from ....models import DocumentAsEntity, LinkerEntity
11
+ from ....repositories import EntityRepository
12
+ from ...chunking_registry import register_chunking_strategy
13
+ from ...chunking_strategy import ChunkingStrategy
14
+ from ...models import Chunk
15
+ from .blm_chunk import BlmChunk
16
+ from .blm_utils import FOOTNOTE_TAG_PATTERN, _preprocess_blm_paragraphs
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ BLM_PARAGRAPH = "blm_paragraph"
21
+ MIN_WORDS_PER_CHUNK = 6 # Сливаем, если <= 5 слов
22
+ MAX_WORDS_PER_CHUNK = 300 # Разделяем, если > 300 слов
23
+
24
+
25
+ @register_chunking_strategy(BLM_PARAGRAPH)
26
+ class BlmParagraphChunkingStrategy(ChunkingStrategy):
27
+ """
28
+ BLM-специфичная стратегия: чанкинг по параграфам с обработкой сносок.
29
+
30
+ - Игнорирует параграфы-сноски (<N>...) и неинформативные параграфы.
31
+ - Создает `BlmChunk`, сохраняя ссылки на сноски `<N>`, найденные в тексте.
32
+ - Формирует `in_search_text` с добавлением текстов сносок.
33
+ - При сборке (`_build_sequenced_chunks`) добавляет блок "Примечания" к группе чанков.
34
+ """
35
+
36
+ DEFAULT_GROUPPER: str = "blm_paragraph"
37
+
38
+ def __init__(self, skip_footnotes: bool = False):
39
+ """Инициализация стратегии.
40
+
41
+ Args:
42
+ skip_footnotes: Если True, сноски будут игнорироваться при обработке.
43
+ """
44
+ self.skip_footnotes = skip_footnotes
45
+ logger.info(f"BlmParagraphChunkingStrategy инициализирована с skip_footnotes={skip_footnotes}")
46
+
47
+ async def chunk_async(
48
+ self,
49
+ document: ParsedDocument,
50
+ doc_entity: DocumentAsEntity,
51
+ ) -> list[LinkerEntity]:
52
+ """Асинхронное разбиение документа.
53
+ Переиспользует синхронную реализацию.
54
+ """
55
+ return self.chunk(document, doc_entity)
56
+
57
+ def chunk(
58
+ self,
59
+ document: ParsedDocument,
60
+ doc_entity: DocumentAsEntity,
61
+ ) -> list[LinkerEntity]:
62
+ """
63
+ Разбивает документ на BlmChunk (параграфы) с обработкой сносок и правил слияния/разделения.
64
+
65
+ Args:
66
+ document: Документ для чанкинга.
67
+ doc_entity: Сущность документа-владельца.
68
+
69
+ Returns:
70
+ Список созданных BlmChunk.
71
+ """
72
+ footnotes_map, valid_paragraphs = _preprocess_blm_paragraphs(
73
+ document.paragraphs, extract_map=not self.skip_footnotes
74
+ )
75
+
76
+ if not self.skip_footnotes:
77
+ if not hasattr(doc_entity, 'metadata') or doc_entity.metadata is None:
78
+ doc_entity.metadata = {}
79
+ doc_entity.metadata['blm_footnotes'] = footnotes_map
80
+ elif hasattr(doc_entity, 'metadata') and doc_entity.metadata is not None:
81
+ # Убираем карту, если она там была и флаг skip=True
82
+ doc_entity.metadata.pop('blm_footnotes', None)
83
+
84
+ # Этап 1: Создание первичных чанков BlmChunk
85
+ initial_chunks: list[BlmChunk] = []
86
+ chunk_index = 0
87
+ for paragraph in valid_paragraphs:
88
+ paragraph_text = paragraph.text
89
+ # Ищем ID сносок только если НЕ пропускаем их
90
+ referenced_ids = []
91
+ if not self.skip_footnotes:
92
+ referenced_ids = [
93
+ int(match.group(1))
94
+ for match in FOOTNOTE_TAG_PATTERN.finditer(paragraph_text)
95
+ ]
96
+
97
+ # Очищаем текст от тегов <N> всегда, т.к. они не нужны в чистом тексте
98
+ clean_text = FOOTNOTE_TAG_PATTERN.sub("", paragraph_text).strip()
99
+ if not clean_text: continue
100
+
101
+ # Формируем search_text: если skip_footnotes=True, он равен clean_text
102
+ search_text = clean_text
103
+ if not self.skip_footnotes and referenced_ids:
104
+ footnote_texts_for_search = []
105
+ unique_sorted_ids = sorted(list(set(referenced_ids)))
106
+ for ref_id in unique_sorted_ids:
107
+ if ref_id in footnotes_map:
108
+ footnote_texts_for_search.append(f"<{ref_id}> {footnotes_map[ref_id]}")
109
+ else:
110
+ logger.warning(f'Ссылка на отсутствующую сноску <{ref_id}> в параграфе: \'{clean_text[:50]}...\'')
111
+ if footnote_texts_for_search:
112
+ search_text += '\n\nПримечания:\n' + '\n'.join(footnote_texts_for_search)
113
+
114
+ chunk_instance = BlmChunk(
115
+ id=uuid4(),
116
+ name=f"{doc_entity.name}_blm_paragraph_{chunk_index}",
117
+ text=clean_text,
118
+ in_search_text=search_text,
119
+ metadata={},
120
+ source_id=None,
121
+ target_id=doc_entity.id,
122
+ number_in_relation=chunk_index,
123
+ groupper=self.DEFAULT_GROUPPER,
124
+ # referenced_ids будет пуст, если skip_footnotes=True
125
+ referenced_footnote_ids=referenced_ids,
126
+ )
127
+ chunk_instance.owner_id = doc_entity.id
128
+ initial_chunks.append(chunk_instance)
129
+ chunk_index += 1
130
+
131
+ if not initial_chunks: return []
132
+
133
+ # Этап 2: Слияние коротких чанков (BlmChunk)
134
+ merged_chunks: list[BlmChunk] = []
135
+ i = 0
136
+ while i < len(initial_chunks):
137
+ current_chunk = initial_chunks[i]
138
+ word_count = len(current_chunk.text.split())
139
+
140
+ if word_count < MIN_WORDS_PER_CHUNK and i + 1 < len(initial_chunks):
141
+ next_chunk = initial_chunks[i + 1]
142
+ merged_text = f"{current_chunk.text}\n\n{next_chunk.text}"
143
+ merged_refs = []
144
+ # Объединяем ссылки только если не пропускаем сноски
145
+ if not self.skip_footnotes:
146
+ merged_refs = sorted(list(set(current_chunk.referenced_footnote_ids + next_chunk.referenced_footnote_ids)))
147
+
148
+ # Перестраиваем search_text для следующего чанка
149
+ merged_search_text = merged_text
150
+ if not self.skip_footnotes and merged_refs:
151
+ merged_footnote_texts = []
152
+ for ref_id in merged_refs:
153
+ if ref_id in footnotes_map: # footnotes_map пуст, если skip_footnotes=True
154
+ merged_footnote_texts.append(f"<{ref_id}> {footnotes_map[ref_id]}")
155
+ if merged_footnote_texts:
156
+ merged_search_text += '\n\nПримечания:\n' + '\n'.join(merged_footnote_texts)
157
+
158
+ next_chunk.text = merged_text
159
+ next_chunk.in_search_text = merged_search_text
160
+ next_chunk.referenced_footnote_ids = merged_refs
161
+ i += 1
162
+ elif word_count < MIN_WORDS_PER_CHUNK and i > 0 and not merged_chunks:
163
+ if merged_chunks:
164
+ prev_chunk = merged_chunks[-1]
165
+ merged_text = f"{prev_chunk.text}\n\n{current_chunk.text}"
166
+ merged_refs = []
167
+ if not self.skip_footnotes:
168
+ merged_refs = sorted(list(set(prev_chunk.referenced_footnote_ids + current_chunk.referenced_footnote_ids)))
169
+
170
+ merged_search_text = merged_text
171
+ if not self.skip_footnotes and merged_refs:
172
+ merged_footnote_texts = []
173
+ for ref_id in merged_refs:
174
+ if ref_id in footnotes_map:
175
+ merged_footnote_texts.append(f"<{ref_id}> {footnotes_map[ref_id]}")
176
+ if merged_footnote_texts:
177
+ merged_search_text += '\n\nПримечания:\n' + '\n'.join(merged_footnote_texts)
178
+
179
+ prev_chunk.text = merged_text
180
+ prev_chunk.in_search_text = merged_search_text
181
+ prev_chunk.referenced_footnote_ids = merged_refs
182
+ i += 1
183
+ else: # Первый короткий
184
+ merged_chunks.append(current_chunk)
185
+ i += 1
186
+ elif word_count < MIN_WORDS_PER_CHUNK and i == 0 and len(initial_chunks) == 1:
187
+ merged_chunks.append(current_chunk)
188
+ i += 1
189
+ else:
190
+ merged_chunks.append(current_chunk)
191
+ i += 1
192
+
193
+ if not merged_chunks: return []
194
+
195
+ # Этап 3: Разделение длинных чанков (BlmChunk)
196
+ final_chunks: list[BlmChunk] = []
197
+ for chunk in merged_chunks:
198
+ words = chunk.text.split()
199
+ if len(words) > MAX_WORDS_PER_CHUNK:
200
+ sub_chunk_texts = []
201
+ for j in range(0, len(words), MAX_WORDS_PER_CHUNK):
202
+ sub_chunk_words = words[j:j + MAX_WORDS_PER_CHUNK]
203
+ sub_chunk_texts.append(" ".join(sub_chunk_words))
204
+
205
+ # Ссылки на сноски копируются, но блок примечаний формируется только если не skip
206
+ chunk_refs = chunk.referenced_footnote_ids if not self.skip_footnotes else []
207
+ footnote_block = ""
208
+ if not self.skip_footnotes and chunk_refs:
209
+ footnote_texts_for_sub_chunks = []
210
+ unique_sorted_refs = sorted(list(set(chunk_refs)))
211
+ for ref_id in unique_sorted_refs:
212
+ if ref_id in footnotes_map:
213
+ footnote_texts_for_sub_chunks.append(f"<{ref_id}> {footnotes_map[ref_id]}")
214
+ if footnote_texts_for_sub_chunks:
215
+ footnote_block = '\n\nПримечания:\n' + '\n'.join(footnote_texts_for_sub_chunks)
216
+
217
+ for part_index, sub_text in enumerate(sub_chunk_texts):
218
+ # search_text включает блок примечаний только если не skip
219
+ sub_search_text = sub_text + footnote_block
220
+ sub_chunk_instance = BlmChunk(
221
+ id=uuid4(),
222
+ name=f"{chunk.name}_part_{part_index}",
223
+ text=sub_text,
224
+ in_search_text=sub_search_text,
225
+ metadata=chunk.metadata.copy(),
226
+ source_id=chunk.source_id,
227
+ target_id=chunk.target_id,
228
+ number_in_relation=-1,
229
+ groupper=chunk.groupper,
230
+ referenced_footnote_ids=chunk_refs, # Список будет пуст, если skip=True
231
+ )
232
+ sub_chunk_instance.owner_id = chunk.owner_id
233
+ final_chunks.append(sub_chunk_instance)
234
+ else:
235
+ final_chunks.append(chunk)
236
+
237
+ # Этап 4: Обновление нумерации и имен
238
+ for final_index, chunk in enumerate(final_chunks):
239
+ chunk.number_in_relation = final_index
240
+ base_name = f"{doc_entity.name}_blm_paragraph_{final_index}"
241
+ if "_part_" in chunk.name:
242
+ chunk.name = base_name + chunk.name[chunk.name.rfind("_part_"):]
243
+ else:
244
+ chunk.name = base_name
245
+
246
+ logger.info(
247
+ f"Документ {doc_entity.name} (BLM Paragraph, skip_footnotes={self.skip_footnotes}) разбит на {len(final_chunks)} чанков."
248
+ )
249
+ return final_chunks
250
+
251
+ @classmethod
252
+ def _build_sequenced_chunks(
253
+ cls,
254
+ repository: EntityRepository,
255
+ group: list[Chunk],
256
+ ) -> str:
257
+ """
258
+ Собирает текст для НЕПРЕРЫВНОЙ последовательности BlmChunk (параграфы).
259
+ Добавляет блок "Примечания" в конце группы, если есть ссылки на сноски.
260
+
261
+ Args:
262
+ repository: Репозиторий для получения карты сносок из документа-владельца.
263
+ group: Список последовательных Chunk (ожидаются BlmChunk).
264
+
265
+ Returns:
266
+ Собранный текст для данной группы с примечаниями.
267
+ """
268
+ if not group:
269
+ return ""
270
+
271
+ if not all(isinstance(c, BlmChunk) for c in group):
272
+ logger.warning(
273
+ "В _build_sequenced_chunks (BLM) передан список, содержащий не BlmChunk. Используется базовая сборка параграфов."
274
+ )
275
+ return "\n\n".join([cls._build_chunk(chunk) for chunk in group])
276
+
277
+ typed_group: list[BlmChunk] = group
278
+
279
+ main_text = "\n\n".join([cls._build_chunk(chunk) for chunk in typed_group])
280
+
281
+ # Проверяем, есть ли вообще ссылки и нужно ли добавлять блок
282
+ all_ref_ids = set()
283
+ if not group or not isinstance(group[0], BlmChunk):
284
+ # На всякий случай, если пришла не та группа
285
+ return "\n\n".join([cls._build_chunk(chunk) for chunk in group])
286
+
287
+ first_blm_chunk = group[0]
288
+ # Проверяем skip_footnotes по первой сущности (предполагаем, что у всех одинаково)
289
+ # TODO: Как надежно узнать skip_footnotes при сборке? Пока предполагаем, что если есть refs, то не skip.
290
+ # Лучше: Передавать skip_footnotes в InjectionBuilder и далее сюда?
291
+ # Пока: если нет ссылок в чанках, блок не добавляем.
292
+ needs_footnote_block = False
293
+ for chunk in group:
294
+ if isinstance(chunk, BlmChunk) and chunk.referenced_footnote_ids:
295
+ all_ref_ids.update(chunk.referenced_footnote_ids)
296
+ needs_footnote_block = True # Нашли х��тя бы одну ссылку
297
+
298
+ # Если не нужно добавлять блок (нет ссылок или skip_footnotes был True при создании)
299
+ if not needs_footnote_block:
300
+ return main_text
301
+
302
+ first_chunk = typed_group[0]
303
+ owner_id = first_chunk.owner_id
304
+ footnotes_map = {}
305
+ if owner_id:
306
+ try:
307
+ doc_entity = repository.get_entity_by_id(owner_id)
308
+ if (
309
+ doc_entity
310
+ and isinstance(doc_entity, (DocumentAsEntity, LinkerEntity))
311
+ and hasattr(doc_entity, 'metadata')
312
+ ):
313
+ entity_metadata = doc_entity.metadata or {}
314
+ footnotes_map = entity_metadata.get('blm_footnotes', {})
315
+ if not footnotes_map:
316
+ logger.warning(
317
+ f"Метаданные 'blm_footnotes' пусты или отсутствуют для документа {owner_id}."
318
+ )
319
+ else:
320
+ logger.error(
321
+ f"Не удалось найти DocumentAsEntity/LinkerEntity с метаданными для ID {owner_id} (группа чанков начиная с {first_chunk.name})"
322
+ )
323
+ except Exception as e:
324
+ logger.error(
325
+ f"Ошибка при получении DocumentAsEntity ({owner_id}) или его метаданных из репозитория: {e}",
326
+ exc_info=True,
327
+ )
328
+ else:
329
+ logger.error(f"У первого чанка {first_chunk.name} отсутствует owner_id.")
330
+
331
+ if not footnotes_map:
332
+ logger.warning(
333
+ f"Карта сносок 'blm_footnotes' не найдена или пуста для документа {owner_id}. Блок примечаний не будет добавлен."
334
+ )
335
+ return main_text
336
+
337
+ footnotes_block_parts = []
338
+ missing_footnotes = []
339
+ for ref_id in sorted(list(all_ref_ids)):
340
+ if ref_id in footnotes_map:
341
+ footnotes_block_parts.append(f"<{ref_id}> {footnotes_map[ref_id]}")
342
+ else:
343
+ missing_footnotes.append(str(ref_id))
344
+ footnotes_block_parts.append(f'<{ref_id}> [Сноска не найдена]')
345
+
346
+ if missing_footnotes:
347
+ logger.warning(
348
+ f'В документе {owner_id} не найдены определения для сносок: {", ".join(missing_footnotes)}'
349
+ )
350
+
351
+ if footnotes_block_parts:
352
+ footnotes_block = '\n\nПримечания:\n' + '\n'.join(footnotes_block_parts)
353
+ return main_text + footnotes_block
354
+ else:
355
+ return main_text
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_sentence_chunking.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BLM-специфичная стратегия чанкинга по предложениям.
3
+ """
4
+
5
+ import logging
6
+ from uuid import uuid4
7
+
8
+ # Импортируем nltk
9
+ try:
10
+ import nltk
11
+ except ImportError:
12
+ logger.exception(
13
+ "Фатальная ошибка: Библиотека NLTK не найдена. Установите ее: pip install nltk "
14
+ "и загрузите необходимые данные: python -m nltk.downloader punkt"
15
+ )
16
+ raise ImportError(
17
+ "Библиотека NLTK не найдена. Установите ее и данные 'punkt'."
18
+ ) from None
19
+
20
+ from ntr_fileparser import ParsedDocument
21
+
22
+ from ....models import DocumentAsEntity, LinkerEntity
23
+ from ....repositories import EntityRepository
24
+ from ...chunking_registry import register_chunking_strategy
25
+ from ...chunking_strategy import ChunkingStrategy
26
+ from ...models import Chunk
27
+ # Импортируем BlmChunk и утилиты
28
+ from .blm_chunk import BlmChunk
29
+ from .blm_utils import FOOTNOTE_TAG_PATTERN, _preprocess_blm_paragraphs
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ BLM_SENTENCE = "blm_sentence"
34
+ MIN_WORDS_PER_CHUNK = 4 # Минимальное кол-во слов для отдельного чанка
35
+ MAX_WORDS_PER_CHUNK = 150 # Максимальное кол-во слов в чанке
36
+
37
+
38
+ @register_chunking_strategy(BLM_SENTENCE)
39
+ class BlmSentenceChunkingStrategy(ChunkingStrategy):
40
+ """
41
+ BLM-специфичная стратегия: чанкинг по предложениям с обработкой сносок.
42
+
43
+ - Игнорирует параграфы-сноски и неинформативные параграфы перед обработкой.
44
+ - Находит теги <N> в тексте, удаляет их перед разбивкой на предложения.
45
+ - Сопоставляет предложения с исходными тегами сносок по позициям.
46
+ - Создает `BlmChunk`, сохраняя ссылки на релевантные сноски.
47
+ - Формирует `in_search_text` с добавлением текстов сносок.
48
+ - При сборке (`_build_sequenced_chunks`) добавляет блок "Примечания".
49
+ """
50
+
51
+ DEFAULT_GROUPPER: str = "blm_sentence"
52
+
53
+ def __init__(self, skip_footnotes: bool = False):
54
+ """Инициализация стратегии.
55
+ Проверяем наличие данных nltk 'punkt', скачиваем при необходимости.
56
+ Args:
57
+ skip_footnotes: Если True, сноски будут игнорироваться при обработке.
58
+ """
59
+ self.skip_footnotes = skip_footnotes
60
+ logger.info(f"BlmSentenceChunkingStrategy инициализирована с skip_footnotes={skip_footnotes}")
61
+
62
+ # Проверка и загрузка nltk 'punkt'
63
+ try:
64
+ nltk.data.find('tokenizers/punkt')
65
+ logger.debug("Данные NLTK 'punkt' найдены.")
66
+ except LookupError:
67
+ logger.info(
68
+ "Данные NLTK 'punkt' не найдены. Попытка автоматической загрузки..."
69
+ )
70
+ try:
71
+ nltk.download('punkt')
72
+ nltk.data.find('tokenizers/punkt') # Повторная проверка
73
+ logger.info("Данные NLTK 'punkt' успешно загружены.")
74
+ except FileNotFoundError:
75
+ logger.exception(
76
+ "Ошибка FileNotFoundError при загрузке данных nltk 'punkt'. "
77
+ "Проверьте права доступа или попробуйте загрузить вручную: \n"
78
+ "import nltk; nltk.download('punkt')"
79
+ )
80
+ raise LookupError(
81
+ "Не удалось найти или загрузить данные NLTK 'punkt' из-за ошибки файловой системы."
82
+ "(см. лог ошибок)"
83
+ ) from None
84
+ except Exception as e:
85
+ logger.exception(
86
+ f"Не удалось автоматически загрузить данные NLTK 'punkt': {e}. "
87
+ "Пожалуйста, загрузите их вручную: запустите Python и выполните:\n"
88
+ "import nltk\nnltk.download('punkt')"
89
+ )
90
+ raise LookupError(
91
+ "Не удалось найти или загрузить данные NLTK 'punkt'. "
92
+ "(см. лог ошибок для инструкции)"
93
+ ) from e
94
+
95
+ async def chunk_async(
96
+ self,
97
+ document: ParsedDocument,
98
+ doc_entity: DocumentAsEntity,
99
+ ) -> list[LinkerEntity]:
100
+ """Асинхрон��ое разбиение документа."""
101
+ return self.chunk(document, doc_entity)
102
+
103
+ def chunk(
104
+ self,
105
+ document: ParsedDocument,
106
+ doc_entity: DocumentAsEntity,
107
+ ) -> list[LinkerEntity]:
108
+ """
109
+ Разбивает документ на BlmChunk (предложения) с обработкой сносок.
110
+ """
111
+ # Передаем флаг в препроцессор
112
+ footnotes_map, valid_paragraphs = _preprocess_blm_paragraphs(
113
+ document.paragraphs, extract_map=not self.skip_footnotes
114
+ )
115
+
116
+ if not self.skip_footnotes:
117
+ if not hasattr(doc_entity, 'metadata') or doc_entity.metadata is None:
118
+ doc_entity.metadata = {}
119
+ doc_entity.metadata['blm_footnotes'] = footnotes_map
120
+ elif hasattr(doc_entity, 'metadata') and doc_entity.metadata is not None:
121
+ doc_entity.metadata.pop('blm_footnotes', None)
122
+
123
+ if not valid_paragraphs:
124
+ logger.info(f"Документ {doc_entity.name} (BLM Sentence) не содержит валидных параграфов для чанкинга.")
125
+ return []
126
+
127
+ # 1. Собираем текст, находим позиции тегов и очищаем
128
+ full_text_parts = []
129
+ original_offsets = []
130
+ current_offset = 0
131
+ for paragraph in valid_paragraphs:
132
+ text = paragraph.text
133
+ matches = list(FOOTNOTE_TAG_PATTERN.finditer(text))
134
+ last_match_end = 0
135
+ clean_part = ""
136
+ for match in matches:
137
+ part_before = text[last_match_end:match.start()]
138
+ clean_part += part_before
139
+ # Сохраняем позицию тега только если не пропускаем сноски
140
+ if not self.skip_footnotes:
141
+ try:
142
+ tag_id = int(match.group(1))
143
+ tag_position_in_clean_text = len(clean_part) + current_offset
144
+ original_offsets.append((tag_position_in_clean_text, tag_position_in_clean_text, tag_id))
145
+ except ValueError: # Пропускаем невалидные теги
146
+ logger.warning(f"Не удалось распознать номер в теге сноски: {match.group(1)} в параграфе.")
147
+ pass
148
+ last_match_end = match.end()
149
+
150
+ part_after = text[last_match_end:]
151
+ clean_part += part_after
152
+
153
+ stripped_clean_part = clean_part.strip()
154
+ if stripped_clean_part:
155
+ full_text_parts.append(stripped_clean_part)
156
+ current_offset += len(stripped_clean_part) + 2
157
+
158
+ if current_offset > 0: current_offset -= 2
159
+ clean_full_text = "\n\n".join(full_text_parts)
160
+ if not clean_full_text: return []
161
+
162
+ # 2. Разбиваем очищенный текст на предложения
163
+ try:
164
+ sentences = nltk.sent_tokenize(clean_full_text, language='russian')
165
+ except Exception as e:
166
+ logger.error(f"Ошибка при токенизации предложений (BLM) в {doc_entity.name}: {e}", exc_info=True)
167
+ return []
168
+
169
+ # Этап 1 (продолжение): Создание первичных чанков BlmChunk
170
+ initial_chunks: list[BlmChunk] = []
171
+ chunk_index = 0
172
+ current_search_offset = 0
173
+ for sentence_text in sentences:
174
+ sentence_text_stripped = sentence_text.strip()
175
+ if not sentence_text_stripped:
176
+ if sentence_text in clean_full_text[current_search_offset:]:
177
+ current_search_offset += len(sentence_text)
178
+ continue
179
+
180
+ sentence_start_pos = clean_full_text.find(sentence_text_stripped, current_search_offset)
181
+ if sentence_start_pos == -1:
182
+ logger.warning(f"Не удалось найти позицию предложения (BLM): \'{sentence_text_stripped[:50]}...\'")
183
+ sentence_start_pos = clean_full_text.find(sentence_text_stripped)
184
+ if sentence_start_pos == -1:
185
+ logger.error("Полностью не удалось найти предложение, пропускаем.")
186
+ current_search_offset += len(sentence_text_stripped)
187
+ continue
188
+
189
+ sentence_end_pos = sentence_start_pos + len(sentence_text_stripped)
190
+ current_search_offset = sentence_end_pos
191
+
192
+ # Находим ID сносок только если не пропускаем их
193
+ referenced_ids = []
194
+ if not self.skip_footnotes:
195
+ referenced_ids = [offset[2] for offset in original_offsets if sentence_start_pos <= offset[0] < sentence_end_pos]
196
+
197
+ # Ф��рмируем search_text
198
+ search_text = sentence_text_stripped
199
+ if not self.skip_footnotes and referenced_ids:
200
+ footnote_texts_for_search = []
201
+ unique_sorted_ids = sorted(list(set(referenced_ids)))
202
+ for ref_id in unique_sorted_ids:
203
+ if ref_id in footnotes_map:
204
+ footnote_texts_for_search.append(f"<{ref_id}> {footnotes_map[ref_id]}")
205
+ else:
206
+ logger.warning(f'Ссылка на отсутствующую сноску <{ref_id}> в предложении: \'{sentence_text_stripped[:50]}...\'')
207
+ if footnote_texts_for_search:
208
+ search_text += '\n\nПримечания:\n' + '\n'.join(footnote_texts_for_search)
209
+
210
+ chunk_instance = BlmChunk(
211
+ id=uuid4(),
212
+ name=f"{doc_entity.name}_blm_sentence_{chunk_index}",
213
+ text=sentence_text_stripped,
214
+ in_search_text=search_text,
215
+ metadata={},
216
+ source_id=None,
217
+ target_id=doc_entity.id,
218
+ number_in_relation=chunk_index,
219
+ groupper=self.DEFAULT_GROUPPER,
220
+ referenced_footnote_ids=referenced_ids,
221
+ )
222
+ chunk_instance.owner_id = doc_entity.id
223
+ initial_chunks.append(chunk_instance)
224
+ chunk_index += 1
225
+
226
+ if not initial_chunks: return []
227
+
228
+ # Этап 2: Слияние коротких чанков (BlmChunk)
229
+ # Логика идентична BlmParagraphChunkingStrategy, но text соединяется пробелом
230
+ merged_chunks: list[BlmChunk] = []
231
+ i = 0
232
+ while i < len(initial_chunks):
233
+ current_chunk = initial_chunks[i]
234
+ word_count = len(current_chunk.text.split())
235
+
236
+ if word_count < MIN_WORDS_PER_CHUNK and i + 1 < len(initial_chunks):
237
+ next_chunk = initial_chunks[i + 1]
238
+ merged_text = f"{current_chunk.text} {next_chunk.text}" # Соединяем пробелом
239
+ merged_refs = []
240
+ if not self.skip_footnotes:
241
+ merged_refs = sorted(list(set(current_chunk.referenced_footnote_ids + next_chunk.referenced_footnote_ids)))
242
+
243
+ merged_search_text = merged_text
244
+ if not self.skip_footnotes and merged_refs:
245
+ merged_footnote_texts = []
246
+ for ref_id in merged_refs:
247
+ if ref_id in footnotes_map:
248
+ merged_footnote_texts.append(f"<{ref_id}> {footnotes_map[ref_id]}")
249
+ if merged_footnote_texts:
250
+ merged_search_text += '\n\nПримечания:\n' + '\n'.join(merged_footnote_texts)
251
+
252
+ next_chunk.text = merged_text
253
+ next_chunk.in_search_text = merged_search_text
254
+ next_chunk.referenced_footnote_ids = merged_refs
255
+ i += 1
256
+ elif word_count < MIN_WORDS_PER_CHUNK and i > 0 and not merged_chunks:
257
+ if merged_chunks:
258
+ prev_chunk = merged_chunks[-1]
259
+ merged_text = f"{prev_chunk.text} {current_chunk.text}" # Соединяем пробелом
260
+ merged_refs = []
261
+ if not self.skip_footnotes:
262
+ merged_refs = sorted(list(set(prev_chunk.referenced_footnote_ids + current_chunk.referenced_footnote_ids)))
263
+
264
+ merged_search_text = merged_text
265
+ if not self.skip_footnotes and merged_refs:
266
+ merged_footnote_texts = []
267
+ for ref_id in merged_refs:
268
+ if ref_id in footnotes_map:
269
+ merged_footnote_texts.append(f"<{ref_id}> {footnotes_map[ref_id]}")
270
+ if merged_footnote_texts:
271
+ merged_search_text += '\n\nПримечания:\n' + '\n'.join(merged_footnote_texts)
272
+
273
+ prev_chunk.text = merged_text
274
+ prev_chunk.in_search_text = merged_search_text
275
+ prev_chunk.referenced_footnote_ids = merged_refs
276
+ i += 1
277
+ else:
278
+ merged_chunks.append(current_chunk)
279
+ i += 1
280
+ elif word_count < MIN_WORDS_PER_CHUNK and i == 0 and len(initial_chunks) == 1:
281
+ merged_chunks.append(current_chunk)
282
+ i += 1
283
+ else:
284
+ merged_chunks.append(current_chunk)
285
+ i += 1
286
+
287
+ if not merged_chunks: return []
288
+
289
+ # Этап 3: Разделение длинных чанков (BlmChunk)
290
+ # Логика идентична BlmParagraphChunkingStrategy
291
+ final_chunks: list[BlmChunk] = []
292
+ for chunk in merged_chunks:
293
+ words = chunk.text.split()
294
+ if len(words) > MAX_WORDS_PER_CHUNK:
295
+ sub_chunk_texts = []
296
+ for j in range(0, len(words), MAX_WORDS_PER_CHUNK):
297
+ sub_chunk_words = words[j:j + MAX_WORDS_PER_CHUNK]
298
+ sub_chunk_texts.append(" ".join(sub_chunk_words))
299
+
300
+ chunk_refs = chunk.referenced_footnote_ids if not self.skip_footnotes else []
301
+ footnote_block = ""
302
+ if not self.skip_footnotes and chunk_refs:
303
+ footnote_texts_for_sub_chunks = []
304
+ unique_sorted_refs = sorted(list(set(chunk_refs)))
305
+ for ref_id in unique_sorted_refs:
306
+ if ref_id in footnotes_map:
307
+ footnote_texts_for_sub_chunks.append(f"<{ref_id}> {footnotes_map[ref_id]}")
308
+ if footnote_texts_for_sub_chunks:
309
+ footnote_block = '\n\nПримечания:\n' + '\n'.join(footnote_texts_for_sub_chunks)
310
+
311
+ for part_index, sub_text in enumerate(sub_chunk_texts):
312
+ sub_search_text = sub_text + footnote_block
313
+ sub_chunk_instance = BlmChunk(
314
+ id=uuid4(),
315
+ name=f"{chunk.name}_part_{part_index}",
316
+ text=sub_text,
317
+ in_search_text=sub_search_text,
318
+ metadata=chunk.metadata.copy(),
319
+ source_id=chunk.source_id,
320
+ target_id=chunk.target_id,
321
+ number_in_relation=-1,
322
+ groupper=chunk.groupper,
323
+ referenced_footnote_ids=chunk_refs,
324
+ )
325
+ sub_chunk_instance.owner_id = chunk.owner_id
326
+ final_chunks.append(sub_chunk_instance)
327
+ else:
328
+ final_chunks.append(chunk)
329
+
330
+ # Этап 4: Обновление нумерации и имен
331
+ # Логика идентична BlmParagraphChunkingStrategy
332
+ for final_index, chunk in enumerate(final_chunks):
333
+ chunk.number_in_relation = final_index
334
+ base_name = f"{doc_entity.name}_blm_sentence_{final_index}"
335
+ if "_part_" in chunk.name:
336
+ chunk.name = base_name + chunk.name[chunk.name.rfind("_part_"):]
337
+ else:
338
+ chunk.name = base_name
339
+
340
+ logger.info(
341
+ f"Документ {doc_entity.name} (BLM Sentence, skip_footnotes={self.skip_footnotes}) разбит на {len(final_chunks)} чанков."
342
+ )
343
+ return final_chunks
344
+
345
+ @classmethod
346
+ def _build_sequenced_chunks(
347
+ cls,
348
+ repository: EntityRepository,
349
+ group: list[Chunk],
350
+ ) -> str:
351
+ """
352
+ Собирает текст для НЕПРЕРЫВНОЙ последовательности BlmChunk (предложения).
353
+ Добавляет блок "Примечания" в конце группы, если нужно.
354
+ """
355
+ if not group:
356
+ return ""
357
+
358
+ if not all(isinstance(c, BlmChunk) for c in group):
359
+ logger.warning("В _build_sequenced_chunks (BLM Sentence) передан список, содержащий не BlmChunk. Используется базовая сборка предложений.")
360
+ return " ".join([cls._build_chunk(chunk) for chunk in group])
361
+
362
+ typed_group: list[BlmChunk] = group
363
+
364
+ main_text = " ".join([cls._build_chunk(chunk) for chunk in typed_group])
365
+
366
+ all_ref_ids = set()
367
+ needs_footnote_block = False
368
+ for chunk in typed_group:
369
+ if chunk.referenced_footnote_ids: # Если список не пуст, значит skip_footnotes=False при создании
370
+ all_ref_ids.update(chunk.referenced_footnote_ids)
371
+ needs_footnote_block = True
372
+
373
+ if not needs_footnote_block:
374
+ return main_text
375
+
376
+ # Логика получения footnotes_map и формирования блока идентична параграфной
377
+ first_chunk = typed_group[0]
378
+ owner_id = first_chunk.owner_id
379
+ footnotes_map = {}
380
+ if owner_id:
381
+ try:
382
+ doc_entity = repository.get_entity_by_id(owner_id)
383
+ if doc_entity and isinstance(doc_entity, (DocumentAsEntity, LinkerEntity)) and hasattr(doc_entity, 'metadata'):
384
+ entity_metadata = doc_entity.metadata or {}
385
+ footnotes_map = entity_metadata.get('blm_footnotes', {})
386
+ if not footnotes_map:
387
+ logger.warning(f"Метаданные 'blm_footnotes' пусты или отсутствуют для документа {owner_id}.")
388
+ else:
389
+ logger.error(f"Не удалось найти DocumentAsEntity/LinkerEntity с метаданными для ID {owner_id}.")
390
+ except Exception as e:
391
+ logger.error(f"Ошибка при получении DocumentAsEntity ({owner_id}) или метаданных: {e}", exc_info=True)
392
+ else:
393
+ logger.error(f"У первого чанка {first_chunk.name} отсутствует owner_id.")
394
+
395
+ if not footnotes_map:
396
+ logger.warning(f"Карта сносок 'blm_footnotes' не найдена для документа {owner_id}. Блок примечаний не будет добавлен.")
397
+ return main_text
398
+
399
+ footnotes_block_parts = []
400
+ missing_footnotes = []
401
+ for ref_id in sorted(list(all_ref_ids)):
402
+ if ref_id in footnotes_map:
403
+ footnotes_block_parts.append(f"<{ref_id}> {footnotes_map[ref_id]}")
404
+ else:
405
+ missing_footnotes.append(str(ref_id))
406
+ footnotes_block_parts.append(f'<{ref_id}> [Сноска не найдена]')
407
+
408
+ if missing_footnotes:
409
+ logger.warning(f'В документе {owner_id} не найдены определения для сносок: {", ".join(missing_footnotes)}')
410
+
411
+ if footnotes_block_parts:
412
+ footnotes_block = '\n\nПримечания:\n' + '\n'.join(footnotes_block_parts)
413
+ return main_text + footnotes_block
414
+ else:
415
+ return main_text
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_utils.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Утилиты для BLM-специфичных стратегий чанкинга.
3
+ """
4
+ import logging
5
+ import re
6
+ from typing import Tuple
7
+
8
+ from ntr_fileparser import ParsedTextBlock
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ # Паттерн для поиска сносок вида <{number}>
14
+ FOOTNOTE_TAG_PATTERN = re.compile(r"<(\d+)>", re.IGNORECASE)
15
+ # Паттерн для определения параграфа, являющегося текстом сноски
16
+ FOOTNOTE_DEF_PATTERN = re.compile(r"^<(\d+)>(.*)", re.IGNORECASE)
17
+ # Паттерн для игнорируемых параграфов (пустые, разделители и т.п.)
18
+ # Проверяет строки, состоящие только из пробелов, -, _, *, ., или полностью пустые
19
+ IGNORE_PARAGRAPH_PATTERN = re.compile(r"^\s*([-_*.\s]*)$", re.UNICODE)
20
+
21
+ def _preprocess_blm_paragraphs(
22
+ paragraphs: list[ParsedTextBlock],
23
+ extract_map: bool = True,
24
+ ) -> Tuple[dict[int, str], list[ParsedTextBlock]]:
25
+ """Извлекает сноски (если extract_map=True), фильтрует невалидные параграфы.
26
+
27
+ Args:
28
+ paragraphs: Список всех параграфов из ParsedDocument.
29
+ extract_map: Если True, извлекает и возвращает карту сносок.
30
+
31
+ Returns:
32
+ Кортеж: (словарь сносок {номер: текст}, список валидных параграфов).
33
+ Словарь будет пустым, если extract_map=False.
34
+ """
35
+ footnotes_map: dict[int, str] = {}
36
+ valid_paragraphs: list[ParsedTextBlock] = []
37
+ ignored_count = 0
38
+ footnote_defs_count = 0
39
+
40
+ for p in paragraphs:
41
+ if not isinstance(p, ParsedTextBlock):
42
+ ignored_count += 1
43
+ continue
44
+
45
+ text_stripped = p.text.strip()
46
+
47
+ # Проверка на определение сноски (только если extract_map=True)
48
+ if extract_map:
49
+ footnote_match = FOOTNOTE_DEF_PATTERN.match(text_stripped)
50
+ if footnote_match:
51
+ try:
52
+ footnote_num = int(footnote_match.group(1))
53
+ footnote_text = footnote_match.group(2).strip()
54
+ footnotes_map[footnote_num] = footnote_text
55
+ footnote_defs_count += 1
56
+ except ValueError:
57
+ logger.warning(f"Не удалось распознать номер в теге сноски: {footnote_match.group(1)}")
58
+ continue # Параграф с определением сноски пропускаем в любом случае
59
+
60
+ # Проверка на игнорируемый/неинформативный параграф
61
+ if not text_stripped or IGNORE_PARAGRAPH_PATTERN.match(text_stripped):
62
+ # Если не извлекаем карту, но это определение сноски - тоже игнорируем
63
+ if not extract_map and FOOTNOTE_DEF_PATTERN.match(text_stripped):
64
+ ignored_count += 1
65
+ footnote_defs_count += 1 # Считаем как определение, даже если не сохраняем
66
+ continue
67
+ # Иначе это просто мусор
68
+ ignored_count += 1
69
+ continue
70
+
71
+ valid_paragraphs.append(p)
72
+
73
+ # Корректируем лог в зависимости от флага
74
+ log_msg_start = f"Предварительная обработка BLM (extract_map={extract_map}): "
75
+ log_msg_parts = []
76
+ if extract_map:
77
+ log_msg_parts.append(f"Найдено определений сносок: {footnote_defs_count}")
78
+ else:
79
+ log_msg_parts.append(f"Проигнорировано определений сносок: {footnote_defs_count}")
80
+
81
+ log_msg_parts.append(f"Игнорировано/пустых параграфов: {ignored_count}")
82
+ log_msg_parts.append(f"Валидных параграфов для чанкинга: {len(valid_paragraphs)}")
83
+
84
+ logger.debug(log_msg_start + ", ".join(log_msg_parts))
85
+
86
+ return footnotes_map, valid_paragraphs
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/paragraph_chunking.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Стратегия чанкинга по абзацам.
3
+ """
4
+
5
+ import logging
6
+ from uuid import uuid4
7
+
8
+ from ntr_fileparser import ParsedDocument, ParsedTextBlock
9
+
10
+ from ...models import DocumentAsEntity, LinkerEntity
11
+ from ...repositories import EntityRepository
12
+ from ..chunking_registry import register_chunking_strategy
13
+ from ..chunking_strategy import ChunkingStrategy
14
+ from ..models import Chunk
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ PARAGRAPH = "paragraph"
19
+ MIN_WORDS_PER_CHUNK = 6 # Сливаем, если <= 5 слов
20
+ MAX_WORDS_PER_CHUNK = 300 # Разделяем, если > 300 слов
21
+
22
+
23
+ @register_chunking_strategy(PARAGRAPH)
24
+ class ParagraphChunkingStrategy(ChunkingStrategy):
25
+ """
26
+ Стратегия чанкинга, разбивающая документ на чанки, где каждый чанк - это абзац.
27
+
28
+ Создает базовые экземпляры `Chunk` для каждого непустого текстового блока.
29
+ При сборке (`dechunk`) соединяет абзацы двойным переносом строки.
30
+ """
31
+
32
+ DEFAULT_GROUPPER: str = "paragraph" # Группа для связывания и сортировки
33
+
34
+ async def chunk_async(
35
+ self,
36
+ document: ParsedDocument,
37
+ doc_entity: DocumentAsEntity,
38
+ ) -> list[LinkerEntity]:
39
+ """Асинхронное разбиение документа на чанки (абзацы)."""
40
+ # Для этой стратегии асинхронность не дает преимуществ
41
+ return self.chunk(document, doc_entity)
42
+
43
+ def chunk(
44
+ self,
45
+ document: ParsedDocument,
46
+ doc_entity: DocumentAsEntity,
47
+ ) -> list[LinkerEntity]:
48
+ """
49
+ Разбивает документ на чанки-абзацы, применяя правила слияния/разделения.
50
+
51
+ Args:
52
+ document: Документ для чанкинга.
53
+ doc_entity: Сущность документа-владельца.
54
+
55
+ Returns:
56
+ Список созданных Chunk.
57
+ """
58
+ # Этап 1: Создание первичных чанков (по параграфам)
59
+ initial_chunks: list[Chunk] = []
60
+ chunk_index = 0
61
+ for paragraph in document.paragraphs:
62
+ if not isinstance(paragraph, ParsedTextBlock) or not paragraph.text.strip():
63
+ continue
64
+ paragraph_text = paragraph.text.strip()
65
+ if not paragraph_text: # Дополнительная проверка после strip
66
+ continue
67
+
68
+ chunk_instance = Chunk(
69
+ id=uuid4(),
70
+ name=f"{doc_entity.name}_paragraph_{chunk_index}", # Временное имя
71
+ text=paragraph_text,
72
+ in_search_text=paragraph_text,
73
+ metadata={},
74
+ source_id=None,
75
+ target_id=doc_entity.id,
76
+ number_in_relation=chunk_index, # Временный индекс
77
+ groupper=self.DEFAULT_GROUPPER,
78
+ )
79
+ chunk_instance.owner_id = doc_entity.id
80
+ initial_chunks.append(chunk_instance)
81
+ chunk_index += 1
82
+
83
+ if not initial_chunks: return []
84
+
85
+ # Этап 2: Слияние коротких чанков
86
+ merged_chunks: list[Chunk] = []
87
+ i = 0
88
+ while i < len(initial_chunks):
89
+ current_chunk = initial_chunks[i]
90
+ word_count = len(current_chunk.text.split())
91
+
92
+ # Логика слияния - аналогична sentence, но соединяем через \n\n
93
+ if word_count < MIN_WORDS_PER_CHUNK and i + 1 < len(initial_chunks):
94
+ next_chunk = initial_chunks[i + 1]
95
+ # Соединяем абзацы через двойной перенос строки
96
+ merged_text = f"{current_chunk.text}\n\n{next_chunk.text}"
97
+ next_chunk.text = merged_text
98
+ next_chunk.in_search_text = merged_text
99
+ i += 1
100
+ elif word_count < MIN_WORDS_PER_CHUNK and i > 0 and not merged_chunks:
101
+ if merged_chunks:
102
+ prev_chunk = merged_chunks[-1]
103
+ merged_text = f"{prev_chunk.text}\n\n{current_chunk.text}"
104
+ prev_chunk.text = merged_text
105
+ prev_chunk.in_search_text = merged_text
106
+ i += 1
107
+ else:
108
+ merged_chunks.append(current_chunk)
109
+ i += 1
110
+ elif word_count < MIN_WORDS_PER_CHUNK and i == 0 and len(initial_chunks) == 1:
111
+ merged_chunks.append(current_chunk)
112
+ i += 1
113
+ else:
114
+ merged_chunks.append(current_chunk)
115
+ i += 1
116
+
117
+ if not merged_chunks: return []
118
+
119
+ # Этап 3: ��азделение длинных чанков
120
+ final_chunks: list[Chunk] = []
121
+ for chunk in merged_chunks:
122
+ words = chunk.text.split()
123
+ if len(words) > MAX_WORDS_PER_CHUNK:
124
+ sub_chunk_texts = []
125
+ # Делим по словам, сохраняя абзацную структуру в _build_sequenced_chunks
126
+ for j in range(0, len(words), MAX_WORDS_PER_CHUNK):
127
+ sub_chunk_words = words[j:j + MAX_WORDS_PER_CHUNK]
128
+ sub_chunk_texts.append(" ".join(sub_chunk_words)) # Соединяем слова пробелом
129
+
130
+ for part_index, sub_text in enumerate(sub_chunk_texts):
131
+ sub_chunk_instance = Chunk(
132
+ id=uuid4(),
133
+ name=f"{chunk.name}_part_{part_index}",
134
+ text=sub_text,
135
+ in_search_text=sub_text,
136
+ metadata=chunk.metadata.copy(),
137
+ source_id=chunk.source_id,
138
+ target_id=chunk.target_id,
139
+ number_in_relation=-1,
140
+ groupper=chunk.groupper,
141
+ )
142
+ sub_chunk_instance.owner_id = chunk.owner_id
143
+ final_chunks.append(sub_chunk_instance)
144
+ else:
145
+ final_chunks.append(chunk)
146
+
147
+ # Этап 4: Обновление нумерации и имен
148
+ for final_index, chunk in enumerate(final_chunks):
149
+ chunk.number_in_relation = final_index
150
+ base_name = f"{doc_entity.name}_paragraph_{final_index}"
151
+ if "_part_" in chunk.name:
152
+ chunk.name = base_name + chunk.name[chunk.name.rfind("_part_"):]
153
+ else:
154
+ chunk.name = base_name
155
+
156
+ logger.info(
157
+ f"Документ {doc_entity.name} (Paragraph) разбит на {len(final_chunks)} чанков (после слияния/разделения)."
158
+ )
159
+ return final_chunks
160
+
161
+ @classmethod
162
+ def _build_sequenced_chunks(
163
+ cls,
164
+ repository: EntityRepository,
165
+ group: list[Chunk],
166
+ ) -> str:
167
+ """
168
+ Собирает текст для НЕПРЕРЫВНОЙ последовательности чанков-абзацев.
169
+
170
+ Соединяет текст абзацев двойным переносом строки.
171
+
172
+ Args:
173
+ repository: Репозиторий (не используется в данной реализации).
174
+ group: Список последовательных Chunk (абзацев).
175
+
176
+ Returns:
177
+ Собранный текст для данной группы.
178
+ """
179
+ # Используем _build_chunk из базового класса, он просто возвращает chunk.text
180
+ return "\n\n".join([cls._build_chunk(chunk) for chunk in group])
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/sentence_chunking.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Стратегия чанкинга по предложениям.
3
+ """
4
+
5
+ import logging
6
+ from uuid import uuid4
7
+
8
+ # Импортируем nltk
9
+ try:
10
+ import nltk
11
+ except ImportError:
12
+ # Эта ошибка фатальна, так как без nltk стратегия не может работать
13
+ logger.exception(
14
+ "Фатальная ошибка: Библиотека NLTK не найдена. Установите ее: pip install nltk "
15
+ "и загрузите необходимые данные: python -m nltk.downloader punkt"
16
+ )
17
+ raise ImportError(
18
+ "Библиотека NLTK не найдена. Установите ее и данные 'punkt'."
19
+ ) from None
20
+
21
+ from ntr_fileparser import ParsedDocument, ParsedTextBlock
22
+
23
+ from ...models import DocumentAsEntity, LinkerEntity
24
+ from ..chunking_registry import register_chunking_strategy
25
+ from ..chunking_strategy import ChunkingStrategy
26
+ from ..models import Chunk
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ SENTENCE = "sentence"
31
+ MIN_WORDS_PER_CHUNK = 4
32
+ MAX_WORDS_PER_CHUNK = 150
33
+
34
+
35
+ @register_chunking_strategy(SENTENCE)
36
+ class SentenceChunkingStrategy(ChunkingStrategy):
37
+ """
38
+ Стратегия чанкинга, разбивающая документ на чанки, где каждый чанк - это предложение.
39
+
40
+ Использует `nltk.sent_tokenize` для разбиения на предложения.
41
+ Создает базовые экземпляры `Chunk`.
42
+ При сборке (`dechunk`) использует стандартное соединение пробелом.
43
+ """
44
+
45
+ DEFAULT_GROUPPER: str = "sentence" # Группа для связывания и сортировки
46
+
47
+ def __init__(self):
48
+ """Инициализация стратегии. Проверяем наличие данных nltk 'punkt', скачиваем при необходимости."""
49
+ try:
50
+ nltk.data.find('tokenizers/punkt')
51
+ logger.debug("Данные NLTK 'punkt' найдены.")
52
+ except LookupError:
53
+ logger.info(
54
+ "Данные NLTK 'punkt' не найдены. Попытка автоматической загрузки..."
55
+ )
56
+ try:
57
+ nltk.download('punkt')
58
+ # Повторная проверка после скачивания
59
+ nltk.data.find('tokenizers/punkt')
60
+ logger.info("Данные NLTK 'punkt' успешно загружены.")
61
+ except FileNotFoundError:
62
+ # Ошибка часто возникает в ограниченных окружениях или если nltk не может найти/создать папку
63
+ logger.exception(
64
+ "Ошибка FileNotFoundError при загрузке данных nltk 'punkt'. "
65
+ "Проверьте права доступа или попробуйте загрузить вручную: \n"
66
+ "import nltk; nltk.download('punkt')"
67
+ )
68
+ raise LookupError(
69
+ "Не удалось найти или загрузить данные NLTK 'punkt' из-за ошибки файловой системы."
70
+ "(см. лог ошибок)"
71
+ ) from None
72
+ except Exception as e:
73
+ logger.exception(
74
+ f"Не удалось автоматически загрузить данные NLTK 'punkt': {e}. "
75
+ "Пожалуйста, загрузите их вручную: запустите Python и выполните:\n"
76
+ "import nltk\nnltk.download('punkt')"
77
+ )
78
+ # Перевыбрасываем исходную ошибку или новую, указывающую на проблему загрузки
79
+ raise LookupError(
80
+ "Не удалось найти или загрузить данные NLTK 'punkt'. "
81
+ "(см. лог ошибок для инструкции)"
82
+ ) from e
83
+
84
+ async def chunk_async(
85
+ self,
86
+ document: ParsedDocument,
87
+ doc_entity: DocumentAsEntity,
88
+ ) -> list[LinkerEntity]:
89
+ """Асинхронное разбиение документа на чанки (предложения)."""
90
+ # Для этой стратегии асинхронность не дает преимуществ
91
+ return self.chunk(document, doc_entity)
92
+
93
+ def chunk(
94
+ self,
95
+ document: ParsedDocument,
96
+ doc_entity: DocumentAsEntity,
97
+ ) -> list[LinkerEntity]:
98
+ """Разбивает документ на чанки-предложения.
99
+
100
+ Args:
101
+ document (ParsedDocument): Документ для чанкинга.
102
+ doc_entity (DocumentAsEntity): Сущность документа-владельца.
103
+
104
+ Returns:
105
+ list[LinkerEntity]: Список созданных Chunk.
106
+ """
107
+ result_chunks: list[Chunk] = []
108
+ chunk_index = 0
109
+
110
+ # 1. Собираем весь текст документа, разделяя параграфы
111
+ full_text_parts = []
112
+ for paragraph in document.paragraphs:
113
+ # Учитываем только текстовые блоки с непустым текстом
114
+ if isinstance(paragraph, ParsedTextBlock) and paragraph.text and paragraph.text.strip():
115
+ full_text_parts.append(paragraph.text.strip())
116
+
117
+ if not full_text_parts:
118
+ logger.info(f"Документ {doc_entity.name} не содержит текста для чанкинга предложениями.")
119
+ return []
120
+
121
+ full_text = "\n\n".join(full_text_parts)
122
+
123
+ # 2. Разбиваем на предложения с помощью nltk
124
+ try:
125
+ # Указываем язык для лучшей токенизации
126
+ sentences = nltk.sent_tokenize(full_text, language='russian')
127
+ except LookupError as e:
128
+ # Эта ошибка должна была быть поймана в __init__, но на всякий случай
129
+ logger.exception(f"Ошибка LookupError при токенизации (данные 'punkt' отсутствуют?): {e}")
130
+ raise # Перевыбрасываем, т.к. это критично
131
+ except Exception as e:
132
+ logger.error(
133
+ f"Неожиданная ошибка при токенизации предложений в документе {doc_entity.name}: {e}",
134
+ exc_info=True
135
+ )
136
+ # В случае неожиданной ошибки возвращаем пустой список, чтобы не падать полностью
137
+ return []
138
+
139
+ # Этап 1: Создание первичных чанков (один на предложение)
140
+ initial_chunks: list[Chunk] = []
141
+ chunk_index = 0
142
+ current_search_offset = 0
143
+ for sentence_text in sentences:
144
+ sentence_text_stripped = sentence_text.strip()
145
+ if not sentence_text_stripped:
146
+ # Обновляем current_search_offset даже для пустых
147
+ if sentence_text in full_text[current_search_offset:]:
148
+ current_search_offset += len(sentence_text)
149
+ continue
150
+
151
+ # Находим позицию для следующего поиска (не используется напрямую, но нужно для offset)
152
+ sentence_start_pos = full_text.find(sentence_text_stripped, current_search_offset)
153
+ if sentence_start_pos != -1:
154
+ current_search_offset = sentence_start_pos + len(sentence_text_stripped)
155
+ else: # На случай если find не сработал, просто двигаем offset
156
+ current_search_offset += len(sentence_text_stripped)
157
+
158
+ chunk_instance = Chunk(
159
+ id=uuid4(),
160
+ name=f"{doc_entity.name}_sentence_{chunk_index}", # Временное имя
161
+ text=sentence_text_stripped,
162
+ in_search_text=sentence_text_stripped,
163
+ metadata={},
164
+ source_id=None,
165
+ target_id=doc_entity.id,
166
+ number_in_relation=chunk_index, # Временный индекс
167
+ groupper=self.DEFAULT_GROUPPER,
168
+ )
169
+ chunk_instance.owner_id = doc_entity.id
170
+ initial_chunks.append(chunk_instance)
171
+ chunk_index += 1
172
+
173
+ if not initial_chunks:
174
+ return [] # Если предложений не нашлось
175
+
176
+ # Этап 2: Слияние коротких чанков
177
+ merged_chunks: list[Chunk] = []
178
+ i = 0
179
+ while i < len(initial_chunks):
180
+ current_chunk = initial_chunks[i]
181
+ word_count = len(current_chunk.text.split())
182
+
183
+ if word_count < MIN_WORDS_PER_CHUNK and i + 1 < len(initial_chunks):
184
+ # Короткий чанк и есть следующий: сливаем с СЛЕДУЮЩИМ
185
+ next_chunk = initial_chunks[i + 1]
186
+ merged_text = f"{current_chunk.text} {next_chunk.text}"
187
+ # Обновляем следующий чанк
188
+ next_chunk.text = merged_text
189
+ next_chunk.in_search_text = merged_text # Для базовой стратегии совпадает
190
+ # Пропускаем текущий (он слит в следующий)
191
+ i += 1
192
+ elif word_count < MIN_WORDS_PER_CHUNK and i > 0 and not merged_chunks:
193
+ # Короткий, ПОСЛЕДНИ�� чанк (merged_chunks пуст, значит предыдущие были длинные)
194
+ # Сливаем с ПРЕДЫДУЩИМ в merged_chunks
195
+ if merged_chunks: # Должен быть не пуст, но проверим
196
+ prev_chunk = merged_chunks[-1]
197
+ merged_text = f"{prev_chunk.text} {current_chunk.text}"
198
+ prev_chunk.text = merged_text
199
+ prev_chunk.in_search_text = merged_text
200
+ i += 1 # Пропускаем текущий
201
+ else: # Не должно произойти, но если первый чанк короткий
202
+ merged_chunks.append(current_chunk)
203
+ i += 1
204
+ elif word_count < MIN_WORDS_PER_CHUNK and i == 0 and len(initial_chunks) == 1:
205
+ # Единственный чанк и он короткий - просто добавляем
206
+ merged_chunks.append(current_chunk)
207
+ i += 1
208
+ else:
209
+ # Достаточно длинный чанк или последний короткий (но не единственный)
210
+ merged_chunks.append(current_chunk)
211
+ i += 1
212
+
213
+ if not merged_chunks: return [] # Если все слилось в никуда (маловероятно)
214
+
215
+ # Этап 3: Разделение длинных чанков
216
+ final_chunks: list[Chunk] = []
217
+ for chunk in merged_chunks:
218
+ words = chunk.text.split()
219
+ if len(words) > MAX_WORDS_PER_CHUNK:
220
+ sub_chunk_texts = []
221
+ for j in range(0, len(words), MAX_WORDS_PER_CHUNK):
222
+ sub_chunk_words = words[j:j + MAX_WORDS_PER_CHUNK]
223
+ sub_chunk_texts.append(" ".join(sub_chunk_words))
224
+
225
+ # Создаем новые чанки для подстрок
226
+ for part_index, sub_text in enumerate(sub_chunk_texts):
227
+ sub_chunk_instance = Chunk(
228
+ id=uuid4(),
229
+ # Имя отражает разделение, но индекс будет обновлен позже
230
+ name=f"{chunk.name}_part_{part_index}",
231
+ text=sub_text,
232
+ in_search_text=sub_text, # Базовая стратегия
233
+ metadata=chunk.metadata.copy(),
234
+ source_id=chunk.source_id,
235
+ target_id=chunk.target_id,
236
+ number_in_relation=-1, # Будет обновлен
237
+ groupper=chunk.groupper,
238
+ )
239
+ sub_chunk_instance.owner_id = chunk.owner_id
240
+ final_chunks.append(sub_chunk_instance)
241
+ else:
242
+ # Чанк не слишком длинный, добавляем как есть
243
+ final_chunks.append(chunk)
244
+
245
+ # Этап 4: Обновление нумерации и имен
246
+ for final_index, chunk in enumerate(final_chunks):
247
+ chunk.number_in_relation = final_index
248
+ # Обновляем имя, если оно было разделено
249
+ base_name = f"{doc_entity.name}_sentence_{final_index}"
250
+ if "_part_" in chunk.name:
251
+ chunk.name = base_name + chunk.name[chunk.name.rfind("_part_"):]
252
+ else:
253
+ chunk.name = base_name
254
+
255
+ logger.info(
256
+ f"Документ {doc_entity.name} (Sentence) разбит на {len(final_chunks)} чанков (после слияния/разделения)."
257
+ )
258
+ return final_chunks
259
+
260
+ # Метод _build_sequenced_chunks не переопределяем,
261
+ # используется базовая реализация с соединением через пробел.
lib/extractor/ntr_text_fragmentation/core/injection_builder.py CHANGED
@@ -2,13 +2,15 @@
2
  Класс для сборки документа из деструктурированных сущностей (чанков, таблиц).
3
  """
4
 
 
5
  import logging
6
  from uuid import UUID
7
 
8
  from ..additors import TablesProcessor
9
  from ..chunking import chunking_registry
10
  from ..models import DocumentAsEntity, LinkerEntity
11
- from ..repositories import EntityRepository, GroupedEntities, InMemoryEntityRepository
 
12
 
13
  # Настраиваем базовый логгер
14
  logger = logging.getLogger(__name__)
@@ -125,9 +127,11 @@ class InjectionBuilder:
125
  }
126
 
127
  groups = sorted(
128
- groups, key=lambda x: document_scores[x.composer.id], reverse=True
129
  )
130
- groups = list(groups)[:max_documents]
 
 
131
 
132
  builded_documents = [
133
  self._build_document(group, include_tables, document_prefix).replace(
@@ -176,3 +180,158 @@ class InjectionBuilder:
176
  )
177
  for group in groups
178
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  Класс для сборки документа из деструктурированных сущностей (чанков, таблиц).
3
  """
4
 
5
+ import asyncio
6
  import logging
7
  from uuid import UUID
8
 
9
  from ..additors import TablesProcessor
10
  from ..chunking import chunking_registry
11
  from ..models import DocumentAsEntity, LinkerEntity
12
+ from ..repositories import (EntityRepository, GroupedEntities,
13
+ InMemoryEntityRepository)
14
 
15
  # Настраиваем базовый логгер
16
  logger = logging.getLogger(__name__)
 
127
  }
128
 
129
  groups = sorted(
130
+ groups, key=lambda x: document_scores.get(x.composer.id, -1.0), reverse=True
131
  )
132
+
133
+ if max_documents is not None:
134
+ groups = groups[:max_documents]
135
 
136
  builded_documents = [
137
  self._build_document(group, include_tables, document_prefix).replace(
 
180
  )
181
  for group in groups
182
  ]
183
+
184
+ async def build_async(
185
+ self,
186
+ entities: list[UUID] | list[LinkerEntity],
187
+ scores: dict[UUID, float] | None = None,
188
+ include_tables: bool = True,
189
+ neighbors_max_distance: int = 1,
190
+ max_documents: int | None = None,
191
+ document_prefix: str = "[Источник] - ",
192
+ ) -> str:
193
+ """
194
+ Асинхронно собирает текст документов на основе списка сущностей.
195
+
196
+ Args:
197
+ entities: Список ID сущностей (UUID) или самих сущностей.
198
+ scores: Словарь оценок {entity_id: score}.
199
+ include_tables: Включать ли таблицы.
200
+ neighbors_max_distance: Макс. расстояние для поиска соседей.
201
+ max_documents: Макс. кол-во документов для включения.
202
+ document_prefix: Префикс для заголовка документа.
203
+
204
+ Returns:
205
+ Собранный текст.
206
+ """
207
+ # Нормализуем до ID, если нужно
208
+ if entities and isinstance(entities[0], LinkerEntity):
209
+ entity_ids = [e.id for e in entities]
210
+ else:
211
+ entity_ids = entities
212
+
213
+ if not entity_ids:
214
+ logger.warning("Не переданы ID сущностей для асинхронной сборки.")
215
+ return ""
216
+
217
+ # Асинхронно получаем сущности по ID
218
+ base_entities = await self.repository.get_entities_by_ids_async(entity_ids)
219
+ if not base_entities:
220
+ logger.warning("Не удалось получить ни одной сущности по переданным ID (async).")
221
+ return ""
222
+
223
+ current_entities = [e.deserialize() for e in base_entities]
224
+
225
+ if neighbors_max_distance > 0:
226
+ neighbors = await self.repository.get_neighboring_entities_async(
227
+ current_entities, neighbors_max_distance
228
+ )
229
+ if neighbors:
230
+ current_entities.extend([e.deserialize() for e in neighbors])
231
+ logger.info(f"Добавлено {len(neighbors)} соседей (async). Общее число: {len(current_entities)}")
232
+
233
+ # Используем переданные scores или генерируем дефолтные
234
+ if scores is None:
235
+ logger.info("Оценки не предоставлены, используем порядковые номера в обратном порядке (async).")
236
+ id_to_score = {entity.id: float(i) for i, entity in enumerate(reversed(current_entities))}
237
+ else:
238
+ id_to_score = scores
239
+
240
+ # Асинхронно группируем сущности
241
+ groups: list[GroupedEntities[DocumentAsEntity]] = (
242
+ await self.repository.group_entities_hierarchically_async(
243
+ entities=current_entities, # Передаем сами сущности
244
+ root_type=DocumentAsEntity,
245
+ sort=True # Сортировка внутри группы
246
+ )
247
+ )
248
+
249
+ logger.info(f"Сгруппировано {len(groups)} документов (async).")
250
+
251
+ if not groups:
252
+ return ""
253
+
254
+ # Вычисляем скоры документов (синхронная операция)
255
+ document_scores = {
256
+ group.composer.id: max(
257
+ id_to_score.get(eid.id, -1.0) for eid in group.entities
258
+ )
259
+ for group in groups
260
+ }
261
+
262
+ # Сортируем группы по скору документа
263
+ groups = sorted(
264
+ groups, key=lambda x: document_scores.get(x.composer.id, -1.0), reverse=True
265
+ )
266
+
267
+ # Ограничиваем количество документов
268
+ if max_documents is not None:
269
+ groups = groups[:max_documents]
270
+
271
+ # Асинхронно собираем каждый документ
272
+ build_tasks = [
273
+ self._build_document_async(group, include_tables, document_prefix)
274
+ for group in groups
275
+ ]
276
+ builded_documents = await asyncio.gather(*build_tasks)
277
+ builded_documents = [doc.replace("\n", "\n\n") for doc in builded_documents if doc is not None]
278
+
279
+ return "\n\n".join(filter(None, builded_documents))
280
+
281
+ async def _build_document_async(
282
+ self,
283
+ group: GroupedEntities[DocumentAsEntity],
284
+ include_tables: bool,
285
+ document_prefix: str,
286
+ ) -> str | None:
287
+ """Асинхронно собирает текст одного документа."""
288
+ document = group.composer
289
+ entities = group.entities # Уже десериализованные
290
+
291
+ if not document or not entities:
292
+ return None
293
+
294
+ name = document.name
295
+ strategy = document.chunking_strategy_ref
296
+ builded_chunks = None
297
+ builded_tables = None
298
+
299
+ tasks_to_gather = []
300
+
301
+ # Задача для сборки чанков
302
+ if strategy:
303
+ try:
304
+ strategy_class = chunking_registry.get(strategy)
305
+ tasks_to_gather.append(strategy_class.dechunk_async(self.repository, entities))
306
+ except KeyError:
307
+ logger.warning(f"Стратегия чанкинга '{strategy}' не найдена для документа {name} (async)")
308
+ tasks_to_gather.append(asyncio.sleep(0, result=None)) # Заглушка None
309
+ else:
310
+ logger.warning(f"Стратегия чанкинга не указана для документа {name} (async)")
311
+ tasks_to_gather.append(asyncio.sleep(0, result=None)) # Заглушка None
312
+
313
+ if include_tables:
314
+ tasks_to_gather.append(self.tables_processor.build_async(self.repository, entities))
315
+ else:
316
+ tasks_to_gather.append(asyncio.sleep(0, result=None)) # Заглушка None
317
+
318
+ try:
319
+ results = await asyncio.gather(*tasks_to_gather)
320
+ builded_chunks = results[0]
321
+ builded_tables = results[1]
322
+ except Exception as e:
323
+ logger.error(f"Ошибка при параллельной сборке чанков/таблиц для документа {name}: {e}", exc_info=True)
324
+ return None
325
+
326
+ # Собираем финальный текст
327
+ result_parts = [f"## {document_prefix}{name}"]
328
+ if builded_chunks:
329
+ result_parts.append(f'### Текст\n{builded_chunks}')
330
+ if builded_tables:
331
+ result_parts.append(f'### Таблицы\n{builded_tables}')
332
+
333
+ # Если не собралось ни чанков, ни таблиц, не возвращаем ничего
334
+ if len(result_parts) <= 1:
335
+ return None
336
+
337
+ return "\n\n".join(result_parts)
lib/extractor/ntr_text_fragmentation/integrations/sqlalchemy/sqlalchemy_repository.py CHANGED
@@ -1,6 +1,7 @@
1
  """
2
  Реализация EntityRepository для работы с SQLAlchemy.
3
  """
 
4
  # Добавляем импорт logging и создаем логгер
5
  import logging
6
  from abc import ABC, abstractmethod
@@ -101,6 +102,11 @@ class SQLAlchemyEntityRepository(EntityRepository, ABC):
101
 
102
  return [self._map_db_entity_to_linker_entity(entity) for entity in db_entities]
103
 
 
 
 
 
 
104
  def group_entities_hierarchically(
105
  self,
106
  entities: Iterable[UUID] | Iterable[LinkerEntity],
@@ -235,6 +241,16 @@ class SQLAlchemyEntityRepository(EntityRepository, ABC):
235
  logger.info(f"[group_hierarchically] Сформировано {len(result)} объектов GroupedEntities.")
236
  return result
237
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  def get_neighboring_entities(
240
  self,
@@ -342,6 +358,14 @@ class SQLAlchemyEntityRepository(EntityRepository, ABC):
342
 
343
  return [self._map_db_entity_to_linker_entity(ne) for ne in neighbor_entities_map.values()]
344
 
 
 
 
 
 
 
 
 
345
 
346
  def get_related_entities(
347
  self,
@@ -453,3 +477,15 @@ class SQLAlchemyEntityRepository(EntityRepository, ABC):
453
  final_map[linker_entity.id] = linker_entity
454
 
455
  return list(final_map.values())
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Реализация EntityRepository для работы с SQLAlchemy.
3
  """
4
+ import asyncio # Добавляем импорт
5
  # Добавляем импорт logging и создаем логгер
6
  import logging
7
  from abc import ABC, abstractmethod
 
102
 
103
  return [self._map_db_entity_to_linker_entity(entity) for entity in db_entities]
104
 
105
+ async def get_entities_by_ids_async(self, entity_ids: Iterable[UUID]) -> List[LinkerEntity]:
106
+ """Асинхронно получить сущности по списку ID."""
107
+ # TODO: Реализовать с использованием async-сессии и await session.execute(...)
108
+ return await asyncio.to_thread(self.get_entities_by_ids, entity_ids)
109
+
110
  def group_entities_hierarchically(
111
  self,
112
  entities: Iterable[UUID] | Iterable[LinkerEntity],
 
241
  logger.info(f"[group_hierarchically] Сформировано {len(result)} объектов GroupedEntities.")
242
  return result
243
 
244
+ async def group_entities_hierarchically_async(
245
+ self,
246
+ entities: Iterable[UUID] | Iterable[LinkerEntity],
247
+ root_type: Type[LinkerEntity],
248
+ max_levels: int = 10,
249
+ sort: bool = True,
250
+ ) -> list[GroupedEntities[LinkerEntity]]:
251
+ """Асинхронно группирует сущности по иерархии."""
252
+ # TODO: Реализовать с использованием async-сессии для реальной асинхронности
253
+ return await asyncio.to_thread(self.group_entities_hierarchically, entities, root_type, max_levels, sort)
254
 
255
  def get_neighboring_entities(
256
  self,
 
358
 
359
  return [self._map_db_entity_to_linker_entity(ne) for ne in neighbor_entities_map.values()]
360
 
361
+ async def get_neighboring_entities_async(
362
+ self,
363
+ entities: Iterable[UUID] | Iterable[LinkerEntity],
364
+ max_distance: int = 1,
365
+ ) -> list[LinkerEntity]:
366
+ """Асинхронно получить соседние сущности."""
367
+ # TODO: Реализовать с использованием async-сессии для реальной асинхронности
368
+ return await asyncio.to_thread(self.get_neighboring_entities, entities, max_distance)
369
 
370
  def get_related_entities(
371
  self,
 
477
  final_map[linker_entity.id] = linker_entity
478
 
479
  return list(final_map.values())
480
+
481
+ async def get_related_entities_async(
482
+ self,
483
+ entities: Iterable[UUID] | Iterable[LinkerEntity],
484
+ relation_type: Type[LinkerEntity] | None = None,
485
+ as_source: bool = False,
486
+ as_target: bool = False,
487
+ as_owner: bool = False,
488
+ ) -> list[LinkerEntity]:
489
+ """Асинхронно получить связанные сущности."""
490
+ # TODO: Реализовать с использованием async-сессии для реальной асинхронности
491
+ return await asyncio.to_thread(self.get_related_entities, entities, relation_type, as_source, as_target, as_owner)
lib/extractor/ntr_text_fragmentation/repositories/entity_repository.py CHANGED
@@ -39,6 +39,13 @@ class EntityRepository(ABC):
39
  """
40
  pass
41
 
 
 
 
 
 
 
 
42
  @abstractmethod
43
  def group_entities_hierarchically(
44
  self,
@@ -62,6 +69,16 @@ class EntityRepository(ABC):
62
  """
63
  pass
64
 
 
 
 
 
 
 
 
 
 
 
65
  @abstractmethod
66
  def get_neighboring_entities(
67
  self,
@@ -81,6 +98,14 @@ class EntityRepository(ABC):
81
  """
82
  pass
83
 
 
 
 
 
 
 
 
 
84
  @abstractmethod
85
  def get_related_entities(
86
  self,
@@ -104,3 +129,14 @@ class EntityRepository(ABC):
104
  Список связанных сущностей и самих связей
105
  """
106
  pass
 
 
 
 
 
 
 
 
 
 
 
 
39
  """
40
  pass
41
 
42
+ async def get_entities_by_ids_async(
43
+ self,
44
+ entity_ids: Iterable[UUID],
45
+ ) -> list[LinkerEntity]:
46
+ """Асинхронно получить сущности по списку идентификаторов."""
47
+ return self.get_entities_by_ids(entity_ids)
48
+
49
  @abstractmethod
50
  def group_entities_hierarchically(
51
  self,
 
69
  """
70
  pass
71
 
72
+ async def group_entities_hierarchically_async(
73
+ self,
74
+ entities: Iterable[UUID] | Iterable[LinkerEntity],
75
+ root_type: Type[LinkerEntity],
76
+ max_levels: int = 10,
77
+ sort: bool = True,
78
+ ) -> list[GroupedEntities[LinkerEntity]]:
79
+ """Асинхронно группирует сущности по корневым элементам иерархии."""
80
+ return self.group_entities_hierarchically(entities, root_type, max_levels, sort)
81
+
82
  @abstractmethod
83
  def get_neighboring_entities(
84
  self,
 
98
  """
99
  pass
100
 
101
+ async def get_neighboring_entities_async(
102
+ self,
103
+ entities: Iterable[UUID] | Iterable[LinkerEntity],
104
+ max_distance: int = 1,
105
+ ) -> list[LinkerEntity]:
106
+ """Асинхронно получить соседние сущности."""
107
+ return self.get_neighboring_entities(entities, max_distance)
108
+
109
  @abstractmethod
110
  def get_related_entities(
111
  self,
 
129
  Список связанных сущностей и самих связей
130
  """
131
  pass
132
+
133
+ async def get_related_entities_async(
134
+ self,
135
+ entities: Iterable[UUID] | Iterable[LinkerEntity],
136
+ relation_type: Type[LinkerEntity] | None = None,
137
+ as_source: bool = False,
138
+ as_target: bool = False,
139
+ as_owner: bool = False,
140
+ ) -> list[LinkerEntity]:
141
+ """Асинхронно получить сущности, связанные с указанными."""
142
+ return self.get_related_entities(entities, relation_type, as_source, as_target, as_owner)
lib/extractor/ntr_text_fragmentation/repositories/in_memory_repository.py CHANGED
@@ -183,8 +183,6 @@ class InMemoryEntityRepository(EntityRepository):
183
  if root_id:
184
  entity_to_root[entity_id] = root_id
185
 
186
- logger.info(f"Найдены корневые элементы для {len(entity_to_root)} сущностей из общего количества {len(entity_ids)}.")
187
-
188
  # Группируем сущности по корневым элементам
189
  root_to_entities: dict[UUID, list[LinkerEntity]] = defaultdict(list)
190
 
 
183
  if root_id:
184
  entity_to_root[entity_id] = root_id
185
 
 
 
186
  # Группируем сущности по корневым элементам
187
  root_to_entities: dict[UUID, list[LinkerEntity]] = defaultdict(list)
188
 
lib/extractor/pyproject.toml CHANGED
@@ -7,7 +7,8 @@ name = "ntr_text_fragmentation"
7
  version = "0.1.0"
8
  dependencies = [
9
  "uuid==1.30",
10
- "ntr_fileparser==0.2.0"
 
11
  ]
12
 
13
  [project.optional-dependencies]
 
7
  version = "0.1.0"
8
  dependencies = [
9
  "uuid==1.30",
10
+ "ntr_fileparser==0.2.0",
11
+ "nltk>=3.8"
12
  ]
13
 
14
  [project.optional-dependencies]
routes/dataset.py CHANGED
@@ -129,7 +129,7 @@ async def make_active(dataset_id: int, dataset_service: Annotated[DatasetService
129
  ) -> DatasetExpanded:
130
  logger.info(f"Handling POST request to /datasets/{dataset_id}/activate")
131
  try:
132
- result = dataset_service.activate_dataset(dataset_id, background_tasks)
133
  logger.info(f"Successfully activated dataset {dataset_id}")
134
  return result
135
  except Exception as e:
 
129
  ) -> DatasetExpanded:
130
  logger.info(f"Handling POST request to /datasets/{dataset_id}/activate")
131
  try:
132
+ result = await dataset_service.activate_dataset(dataset_id, background_tasks)
133
  logger.info(f"Successfully activated dataset {dataset_id}")
134
  return result
135
  except Exception as e:
routes/entity.py CHANGED
@@ -91,7 +91,7 @@ async def search_entities_with_text(
91
  try:
92
  # Получаем результаты поиска
93
  _, scores, entity_ids = entity_service.search_similar_old(
94
- request.query, request.dataset_id
95
  )
96
 
97
  # Проверяем, что scores и entity_ids - корректные numpy массивы
@@ -108,8 +108,9 @@ async def search_entities_with_text(
108
  sorted_scores = [float(scores[i]) for i in sorted_indices]
109
  sorted_ids = [UUID(entity_ids[i]) for i in sorted_indices]
110
 
111
- # Получаем тексты чанков
112
- chunks = entity_service.chunk_repository.get_entities_by_ids(sorted_ids)
 
113
 
114
  # Формируем ответ
115
  return EntitySearchWithTextResponse(
@@ -150,14 +151,18 @@ async def build_entity_text(
150
  Собранный текст
151
  """
152
  try:
 
 
 
153
  if not request.entities:
154
  raise HTTPException(
155
  status_code=404, detail="No entities found with provided IDs"
156
  )
157
 
158
- # Собираем текст
159
- text = entity_service.build_text(
160
  entities=request.entities,
 
161
  chunk_scores=request.chunk_scores,
162
  include_tables=request.include_tables,
163
  max_documents=request.max_documents,
@@ -190,14 +195,17 @@ async def get_entity_info(
190
  # Создаем репозиторий, передавая sessionmaker
191
  chunk_repository = ChunkRepository(db)
192
 
193
- # Получаем общее количество сущностей
194
- total_entities_count = chunk_repository.count_entities_by_dataset_id(dataset_id)
195
-
196
- # Получаем сущности, готовые к поиску (с текстом и эмбеддингом)
197
- searchable_entities, searchable_embeddings = (
198
- chunk_repository.get_searching_entities(dataset_id)
199
  )
200
 
 
 
 
 
 
 
201
  # Проверка, найдены ли сущности, готовые к поиску
202
  # Можно оставить проверку, чтобы не возвращать пустые примеры, если таких нет,
203
  # но основная ошибка 404 должна базироваться на total_entities_count
 
91
  try:
92
  # Получаем результаты поиска
93
  _, scores, entity_ids = entity_service.search_similar_old(
94
+ request.query, request.dataset_id, 100
95
  )
96
 
97
  # Проверяем, что scores и entity_ids - корректные numpy массивы
 
108
  sorted_scores = [float(scores[i]) for i in sorted_indices]
109
  sorted_ids = [UUID(entity_ids[i]) for i in sorted_indices]
110
 
111
+ chunks = await entity_service.chunk_repository.get_entities_by_ids_async(
112
+ sorted_ids
113
+ )
114
 
115
  # Формируем ответ
116
  return EntitySearchWithTextResponse(
 
151
  Собранный текст
152
  """
153
  try:
154
+ if request.dataset_id is None:
155
+ raise HTTPException(status_code=400, detail="dataset_id is required")
156
+
157
  if not request.entities:
158
  raise HTTPException(
159
  status_code=404, detail="No entities found with provided IDs"
160
  )
161
 
162
+ # Собираем текст асинхронно
163
+ text = await entity_service.build_text_async(
164
  entities=request.entities,
165
+ dataset_id=request.dataset_id,
166
  chunk_scores=request.chunk_scores,
167
  include_tables=request.include_tables,
168
  max_documents=request.max_documents,
 
195
  # Создаем репозиторий, передавая sessionmaker
196
  chunk_repository = ChunkRepository(db)
197
 
198
+ # Получаем общее количество сущностей асинхронно
199
+ total_entities_count = await chunk_repository.count_entities_by_dataset_id_async(
200
+ dataset_id
 
 
 
201
  )
202
 
203
+ # Получаем сущности, готовые к поиску (с текстом и эмбеддингом) асинхронно
204
+ (
205
+ searchable_entities,
206
+ searchable_embeddings,
207
+ ) = await chunk_repository.get_searching_entities_async(dataset_id)
208
+
209
  # Проверка, найдены ли сущности, готовые к поиску
210
  # Можно оставить проверку, чтобы не возвращать пустые примеры, если таких нет,
211
  # но основная ошибка 404 должна базироваться на total_entities_count
routes/llm.py CHANGED
@@ -1,7 +1,7 @@
1
  import json
2
  import logging
3
  import os
4
- from typing import Annotated, AsyncGenerator, List, Optional
5
 
6
  from fastapi import APIRouter, Depends, HTTPException
7
  from fastapi.responses import StreamingResponse
@@ -14,7 +14,7 @@ from components.llm.common import (ChatRequest, LlmParams, LlmPredictParams,
14
  from components.llm.deepinfra_api import DeepInfraApi
15
  from components.llm.utils import append_llm_response_to_history
16
  from components.services.dataset import DatasetService
17
- from components.services.dialogue import DialogueService, QEResult
18
  from components.services.entity import EntityService
19
  from components.services.llm_config import LLMConfigService
20
  from components.services.llm_prompt import LlmPromptService
@@ -70,16 +70,13 @@ def insert_search_results_to_message(
70
  return False
71
 
72
  def try_insert_search_results(
73
- chat_request: ChatRequest, search_results: List[str], entities: List[List[str]]
74
  ) -> bool:
75
- i = 0
76
  for msg in reversed(chat_request.history):
77
- if msg.role == "user" and not msg.searchResults:
78
- msg.searchResults = search_results[i]
79
- msg.searchEntities = entities[i]
80
- i += 1
81
- if i == len(search_results):
82
- return True
83
  return False
84
 
85
  def try_insert_reasoning(
@@ -93,31 +90,70 @@ def collapse_history_to_first_message(chat_request: ChatRequest) -> ChatRequest:
93
  """
94
  Сворачивает историю в первое сообщение и возвращает новый объект ChatRequest.
95
  Формат:
96
- role: текст сообщения
97
- <reasoning>[Источник] - текст</reasoning>
98
- <search-results>[Источник] - текст</search-results>
99
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  """
101
  if not chat_request.history:
102
  return ChatRequest(history=[])
 
 
 
 
 
103
 
104
  # Собираем историю в одну строку
105
  collapsed_content = []
106
- for msg in chat_request.history:
107
- # Добавляем текст сообщения с указанием роли
108
  if msg.content.strip():
109
- collapsed_content.append(f"{msg.role.strip()}: {msg.content.strip()}")
110
- # Добавляем reasoning, если есть
111
- if msg.reasoning.strip():
112
- collapsed_content.append(f"<reasoning>{msg.reasoning}</reasoning>")
113
- # Добавляем search-results, если они есть
114
- if msg.searchResults.strip():
115
- collapsed_content.append(f"<search-results>{msg.searchResults}</search-results>")
 
 
 
 
 
 
 
 
 
 
 
116
 
117
- # Формируем финальный текст с переносами строк
118
- new_content = "\n".join(collapsed_content)
 
 
119
 
120
- # Создаем новое сообщение и новый объект ChatRequest
121
  new_message = Message(
122
  role='user',
123
  content=new_content,
@@ -134,6 +170,17 @@ async def sse_generator(request: ChatRequest, llm_api: DeepInfraApi, system_prom
134
  Генератор для стриминга ответа LLM через SSE.
135
  """
136
  try:
 
 
 
 
 
 
 
 
 
 
 
137
  qe_result = await dialogue_service.get_qe_result(request.history)
138
  try_insert_reasoning(request, qe_result.debug_message)
139
 
@@ -162,12 +209,12 @@ async def sse_generator(request: ChatRequest, llm_api: DeepInfraApi, system_prom
162
  dataset = dataset_service.get_current_dataset()
163
  if dataset is None:
164
  raise HTTPException(status_code=400, detail="Dataset not found")
165
- previous_entities = [msg.searchEntities for msg in request.history if msg.searchEntities is not None]
166
- previous_entities, chunk_ids, scores = entity_service.search_similar(qe_result.search_query,
167
- dataset.id, previous_entities)
168
- text_chunks = entity_service.build_text(chunk_ids, scores)
169
- all_text_chunks = [text_chunks] + [entity_service.build_text(entities) for entities in previous_entities]
170
- all_entities = [chunk_ids] + previous_entities
171
 
172
  search_results_event = {
173
  "event": "search_results",
@@ -180,7 +227,7 @@ async def sse_generator(request: ChatRequest, llm_api: DeepInfraApi, system_prom
180
 
181
  # new_message = f'<search-results>\n{text_chunks}\n</search-results>\n{last_query.content}'
182
 
183
- try_insert_search_results(request, all_text_chunks, all_entities)
184
  except Exception as e:
185
  logger.error(f"Error in SSE chat stream while searching: {str(e)}", stack_info=True)
186
  yield "data: {\"event\": \"error\", \"data\":\""+str(e)+"\" }\n\n"
@@ -294,7 +341,7 @@ async def chat(
294
  logger.info(f"chunk_ids: {chunk_ids[:3]}...{chunk_ids[-3:]}")
295
  logger.info(f"scores: {scores[:3]}...{scores[-3:]}")
296
 
297
- text_chunks = entity_service.build_text(chunks, scores)
298
 
299
  logger.info(f"text_chunks: {text_chunks[:3]}...{text_chunks[-3:]}")
300
 
 
1
  import json
2
  import logging
3
  import os
4
+ from typing import Annotated, AsyncGenerator, Optional
5
 
6
  from fastapi import APIRouter, Depends, HTTPException
7
  from fastapi.responses import StreamingResponse
 
14
  from components.llm.deepinfra_api import DeepInfraApi
15
  from components.llm.utils import append_llm_response_to_history
16
  from components.services.dataset import DatasetService
17
+ from components.services.dialogue import DialogueService
18
  from components.services.entity import EntityService
19
  from components.services.llm_config import LLMConfigService
20
  from components.services.llm_prompt import LlmPromptService
 
70
  return False
71
 
72
  def try_insert_search_results(
73
+ chat_request: ChatRequest, search_results: str
74
  ) -> bool:
 
75
  for msg in reversed(chat_request.history):
76
+ if msg.role == "user":
77
+ msg.searchResults = search_results
78
+ msg.searchEntities = []
79
+ return True
 
 
80
  return False
81
 
82
  def try_insert_reasoning(
 
90
  """
91
  Сворачивает историю в первое сообщение и возвращает новый объект ChatRequest.
92
  Формат:
93
+ <history>
94
+ <user>
95
+ текст сообщения
96
+ </user>
97
+ <reasoning>
98
+ текст reasoning
99
+ </reasoning>
100
+ <search-results>
101
+ текст search-results
102
+ </search-results>
103
+ <assistant>
104
+ текст ответа
105
+ </assistant>
106
+ </history>
107
+
108
+ <last-request>
109
+ <reasoning>
110
+ текст reasoning
111
+ </reasoning>
112
+ <search-results>
113
+ текст search-results
114
+ </search-results>
115
+ user:
116
+ текст последнего запроса
117
+ </last-request>
118
+ assistant:
119
  """
120
  if not chat_request.history:
121
  return ChatRequest(history=[])
122
+
123
+ last_user_message = chat_request.history[-1]
124
+ if chat_request.history[-1].role != "user":
125
+ logger.warning("Last message is not user message")
126
+
127
 
128
  # Собираем историю в одну строку
129
  collapsed_content = []
130
+ collapsed_content.append("<history>\n")
131
+ for msg in chat_request.history[:-1]:
132
  if msg.content.strip():
133
+ tabulated_content = msg.content.strip().replace("\n", "\n\t\t")
134
+ collapsed_content.append(f"\t<{msg.role.strip()}>\n\t\t{tabulated_content}\n\t</{msg.role.strip()}>\n")
135
+ if msg.role == "user":
136
+ tabulated_reasoning = msg.reasoning.strip().replace("\n", "\n\t\t")
137
+ tabulated_search_results = msg.searchResults.strip().replace("\n", "\n\t\t")
138
+ collapsed_content.append(f"\t<reasoning>\n\t\t{tabulated_reasoning}\n\t</reasoning>\n")
139
+ collapsed_content.append(f"\t<search-results>\n\t\t{tabulated_search_results}\n\t</search-results>\n")
140
+
141
+ collapsed_content.append("</history>\n")
142
+
143
+ collapsed_content.append("<last-request>\n")
144
+ if last_user_message.content.strip():
145
+ tabulated_content = last_user_message.content.strip().replace("\n", "\n\t\t")
146
+ tabulated_reasoning = last_user_message.reasoning.strip().replace("\n", "\n\t\t")
147
+ tabulated_search_results = last_user_message.searchResults.strip().replace("\n", "\n\t\t")
148
+ collapsed_content.append(f"\t<reasoning>\n\t\t{tabulated_reasoning}\n\t</reasoning>\n")
149
+ collapsed_content.append(f"\t<search-results>\n\t\t{tabulated_search_results}\n\t</search-results>\n")
150
+ collapsed_content.append(f"\tuser: \n\t\t{tabulated_content}\n")
151
 
152
+ collapsed_content.append("</last-request>\n")
153
+
154
+ collapsed_content.append("assistant:\n")
155
+ new_content = "".join(collapsed_content)
156
 
 
157
  new_message = Message(
158
  role='user',
159
  content=new_content,
 
170
  Генератор для стриминга ответа LLM через SSE.
171
  """
172
  try:
173
+ old_history = request.history
174
+ new_history = [Message(
175
+ role=msg.role,
176
+ content=msg.content,
177
+ reasoning=msg.reasoning,
178
+ searchResults='', #msg.searchResults[:10000] + "..." if msg.searchResults else '',
179
+ searchEntities=[],
180
+ ) for msg in old_history]
181
+ request.history = new_history
182
+
183
+
184
  qe_result = await dialogue_service.get_qe_result(request.history)
185
  try_insert_reasoning(request, qe_result.debug_message)
186
 
 
209
  dataset = dataset_service.get_current_dataset()
210
  if dataset is None:
211
  raise HTTPException(status_code=400, detail="Dataset not found")
212
+ _, chunk_ids, scores = entity_service.search_similar(
213
+ qe_result.search_query,
214
+ dataset.id,
215
+ [],
216
+ )
217
+ text_chunks = await entity_service.build_text_async(chunk_ids, dataset.id, scores)
218
 
219
  search_results_event = {
220
  "event": "search_results",
 
227
 
228
  # new_message = f'<search-results>\n{text_chunks}\n</search-results>\n{last_query.content}'
229
 
230
+ try_insert_search_results(request, text_chunks)
231
  except Exception as e:
232
  logger.error(f"Error in SSE chat stream while searching: {str(e)}", stack_info=True)
233
  yield "data: {\"event\": \"error\", \"data\":\""+str(e)+"\" }\n\n"
 
341
  logger.info(f"chunk_ids: {chunk_ids[:3]}...{chunk_ids[-3:]}")
342
  logger.info(f"scores: {scores[:3]}...{scores[-3:]}")
343
 
344
+ text_chunks = await entity_service.build_text_async(chunk_ids, dataset.id, scores)
345
 
346
  logger.info(f"text_chunks: {text_chunks[:3]}...{text_chunks[-3:]}")
347