Spaces:
Runtime error
Runtime error
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 |
|