Spaces:
Sleeping
Sleeping
update
Browse files- common/configuration.py +1 -1
- common/dependencies.py +14 -6
- components/dbo/chunk_repository.py +64 -0
- components/llm/prompts.py +159 -0
- components/llm/utils.py +18 -12
- components/services/dataset.py +38 -7
- components/services/dialogue.py +8 -16
- components/services/entity.py +116 -25
- components/services/search_metrics.py +9 -3
- config_dev.yaml +2 -2
- lib/extractor/ntr_text_fragmentation/additors/tables/table_processor.py +91 -2
- lib/extractor/ntr_text_fragmentation/additors/tables_processor.py +39 -2
- lib/extractor/ntr_text_fragmentation/chunking/chunking_strategy.py +13 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/__init__.py +23 -4
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/__init__.py +18 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_chunk.py +66 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_paragraph_chunking.py +355 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_sentence_chunking.py +415 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/blm/blm_utils.py +86 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/paragraph_chunking.py +180 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/sentence_chunking.py +261 -0
- lib/extractor/ntr_text_fragmentation/core/injection_builder.py +162 -3
- lib/extractor/ntr_text_fragmentation/integrations/sqlalchemy/sqlalchemy_repository.py +36 -0
- lib/extractor/ntr_text_fragmentation/repositories/entity_repository.py +36 -0
- lib/extractor/ntr_text_fragmentation/repositories/in_memory_repository.py +0 -2
- lib/extractor/pyproject.toml +2 -1
- routes/dataset.py +1 -1
- routes/entity.py +19 -11
- 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 |
-
|
19 |
-
# Преобразуем историю из ChatRequest
|
20 |
for message in request.history:
|
21 |
-
content
|
22 |
-
|
23 |
-
search_results = "\n" + message.searchResults
|
24 |
-
content += f"\n<search-results>\n{search_results}\n</search-results>"
|
25 |
|
26 |
-
|
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 |
-
|
|
|
270 |
else:
|
271 |
-
|
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
|
5 |
|
6 |
from pydantic import BaseModel
|
7 |
|
8 |
-
from common
|
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(
|
135 |
-
|
|
|
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,
|
|
|
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 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
143 |
|
144 |
# Сохраняем в базу
|
145 |
-
self.chunk_repository.
|
146 |
|
147 |
logger.info(f"Added {len(entities)} entities to dataset {dataset_id}")
|
148 |
|
149 |
-
def
|
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 |
-
|
|
|
162 |
include_tables: Флаг включения таблиц
|
163 |
max_documents: Максимальное количество документов
|
164 |
|
165 |
Returns:
|
166 |
Собранный текст
|
167 |
"""
|
168 |
-
|
169 |
-
|
170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
171 |
if chunk_scores is not None:
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
|
|
|
|
|
|
|
|
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 |
-
|
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 |
-
|
|
|
|
|
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 |
-
|
|
|
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 |
-
|
396 |
-
|
|
|
|
|
|
|
|
|
|
|
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:
|
22 |
-
max_entities_per_dialogue:
|
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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
8 |
-
|
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,
|
|
|
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
|
129 |
)
|
130 |
-
|
|
|
|
|
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 |
-
|
|
|
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.
|
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.
|
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,
|
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
|
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:
|
74 |
) -> bool:
|
75 |
-
i = 0
|
76 |
for msg in reversed(chat_request.history):
|
77 |
-
if msg.role == "user"
|
78 |
-
msg.searchResults = search_results
|
79 |
-
msg.searchEntities =
|
80 |
-
|
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 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
100 |
"""
|
101 |
if not chat_request.history:
|
102 |
return ChatRequest(history=[])
|
|
|
|
|
|
|
|
|
|
|
103 |
|
104 |
# Собираем историю в одну строку
|
105 |
collapsed_content = []
|
106 |
-
|
107 |
-
|
108 |
if msg.content.strip():
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
|
117 |
-
|
118 |
-
|
|
|
|
|
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 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
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,
|
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.
|
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 |
|