muryshev commited on
Commit
86c402d
·
1 Parent(s): fbf2abd
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +5 -2
  2. common/db.py +1 -2
  3. common/dependencies.py +59 -38
  4. components/dbo/chunk_repository.py +249 -0
  5. components/dbo/models/dataset.py +6 -1
  6. components/dbo/models/entity.py +85 -0
  7. components/embedding_extraction.py +11 -8
  8. components/nmd/faiss_vector_search.py +25 -14
  9. components/services/dataset.py +85 -137
  10. components/services/document.py +12 -12
  11. components/services/entity.py +210 -0
  12. lib/extractor/.cursor/rules/project-description.mdc +86 -0
  13. lib/extractor/.gitignore +11 -0
  14. lib/extractor/README.md +60 -0
  15. lib/extractor/docs/architecture.puml +149 -0
  16. lib/extractor/ntr_text_fragmentation/__init__.py +19 -0
  17. lib/extractor/ntr_text_fragmentation/additors/__init__.py +10 -0
  18. lib/extractor/ntr_text_fragmentation/additors/tables/__init__.py +5 -0
  19. lib/extractor/ntr_text_fragmentation/additors/tables/table_entity.py +74 -0
  20. lib/extractor/ntr_text_fragmentation/additors/tables_processor.py +117 -0
  21. lib/extractor/ntr_text_fragmentation/chunking/__init__.py +11 -0
  22. lib/extractor/ntr_text_fragmentation/chunking/chunking_strategy.py +86 -0
  23. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/__init__.py +11 -0
  24. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size/__init__.py +9 -0
  25. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size/fixed_size_chunk.py +143 -0
  26. lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size_chunking.py +568 -0
  27. lib/extractor/ntr_text_fragmentation/core/__init__.py +9 -0
  28. lib/extractor/ntr_text_fragmentation/core/destructurer.py +143 -0
  29. lib/extractor/ntr_text_fragmentation/core/entity_repository.py +258 -0
  30. lib/extractor/ntr_text_fragmentation/core/injection_builder.py +429 -0
  31. lib/extractor/ntr_text_fragmentation/integrations/__init__.py +9 -0
  32. lib/extractor/ntr_text_fragmentation/integrations/sqlalchemy_repository.py +339 -0
  33. lib/extractor/ntr_text_fragmentation/models/__init__.py +13 -0
  34. lib/extractor/ntr_text_fragmentation/models/chunk.py +48 -0
  35. lib/extractor/ntr_text_fragmentation/models/document.py +49 -0
  36. lib/extractor/ntr_text_fragmentation/models/linker_entity.py +217 -0
  37. lib/extractor/pyproject.toml +26 -0
  38. lib/extractor/scripts/README_test_chunking.md +107 -0
  39. lib/extractor/scripts/analyze_missing_puncts.py +547 -0
  40. lib/extractor/scripts/combine_results.py +1352 -0
  41. lib/extractor/scripts/debug_question_chunks.py +392 -0
  42. lib/extractor/scripts/evaluate_chunking.py +800 -0
  43. lib/extractor/scripts/plot_macro_metrics.py +348 -0
  44. lib/extractor/scripts/prepare_dataset.py +578 -0
  45. lib/extractor/scripts/run_chunking_experiments.sh +156 -0
  46. lib/extractor/scripts/run_experiments.py +206 -0
  47. lib/extractor/scripts/search_api.py +748 -0
  48. lib/extractor/scripts/test_chunking_visualization.py +235 -0
  49. lib/extractor/tests/__init__.py +3 -0
  50. lib/extractor/tests/chunking/__init__.py +3 -0
Dockerfile CHANGED
@@ -30,13 +30,16 @@ RUN python -m pip install \
30
  torch==2.6.0+cu126 \
31
  --index-url https://download.pytorch.org/whl/cu126
32
 
 
33
  COPY requirements.txt /app/
34
  RUN python -m pip install -r requirements.txt
35
- # RUN python -m pip install --ignore-installed elasticsearch==7.11.0 || true
36
 
37
  COPY . .
 
 
 
38
 
39
- # RUN mkdir -p /data/regulation_datasets /data/documents /data/logs
40
 
41
 
42
  EXPOSE ${PORT}
 
30
  torch==2.6.0+cu126 \
31
  --index-url https://download.pytorch.org/whl/cu126
32
 
33
+
34
  COPY requirements.txt /app/
35
  RUN python -m pip install -r requirements.txt
 
36
 
37
  COPY . .
38
+ RUN python -m pip install -e ./lib/parser
39
+ RUN python -m pip install --no-deps -e ./lib/extractor
40
+ # RUN python -m pip install --ignore-installed elasticsearch==7.11.0 || true
41
 
42
+ RUN mkdir -p /data/regulation_datasets /data/documents /logs
43
 
44
 
45
  EXPOSE ${PORT}
common/db.py CHANGED
@@ -16,13 +16,12 @@ import components.dbo.models.document
16
  import components.dbo.models.log
17
  import components.dbo.models.llm_prompt
18
  import components.dbo.models.llm_config
19
-
20
 
21
  CONFIG_PATH = os.environ.get('CONFIG_PATH', './config_dev.yaml')
22
  config = Configuration(CONFIG_PATH)
23
  logger = logging.getLogger(__name__)
24
 
25
- print("sql url:", config.common_config.log_sql_path)
26
  engine = create_engine(config.common_config.log_sql_path, connect_args={'check_same_thread': False})
27
 
28
  session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
 
16
  import components.dbo.models.log
17
  import components.dbo.models.llm_prompt
18
  import components.dbo.models.llm_config
19
+ import components.dbo.models.entity
20
 
21
  CONFIG_PATH = os.environ.get('CONFIG_PATH', './config_dev.yaml')
22
  config = Configuration(CONFIG_PATH)
23
  logger = logging.getLogger(__name__)
24
 
 
25
  engine = create_engine(config.common_config.log_sql_path, connect_args={'check_same_thread': False})
26
 
27
  session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
common/dependencies.py CHANGED
@@ -1,21 +1,22 @@
1
  import logging
2
- from logging import Logger
3
  import os
 
 
 
4
  from fastapi import Depends
 
 
5
 
6
  from common.configuration import Configuration
 
 
 
7
  from components.llm.common import LlmParams
8
  from components.llm.deepinfra_api import DeepInfraApi
9
  from components.services.dataset import DatasetService
10
- from components.embedding_extraction import EmbeddingExtractor
11
- from components.datasets.dispatcher import Dispatcher
12
  from components.services.document import DocumentService
13
- from components.services.acronym import AcronymService
14
  from components.services.llm_config import LLMConfigService
15
-
16
- from typing import Annotated
17
- from sqlalchemy.orm import sessionmaker, Session
18
- from common.db import session_factory
19
  from components.services.llm_prompt import LlmPromptService
20
 
21
 
@@ -28,56 +29,76 @@ def get_db() -> sessionmaker:
28
 
29
 
30
  def get_logger() -> Logger:
31
- return logging.getLogger(__name__)
32
 
33
 
34
- def get_embedding_extractor(config: Annotated[Configuration, Depends(get_config)]) -> EmbeddingExtractor:
 
 
35
  return EmbeddingExtractor(
36
  config.db_config.faiss.model_embedding_path,
37
  config.db_config.faiss.device,
38
  )
39
 
40
 
41
- def get_dataset_service(
 
 
 
 
 
 
 
 
 
 
42
  vectorizer: Annotated[EmbeddingExtractor, Depends(get_embedding_extractor)],
 
43
  config: Annotated[Configuration, Depends(get_config)],
44
- db: Annotated[sessionmaker, Depends(get_db)]
45
- ) -> DatasetService:
46
- return DatasetService(vectorizer, config, db)
47
 
48
- def get_dispatcher(vectorizer: Annotated[EmbeddingExtractor, Depends(get_embedding_extractor)],
49
- config: Annotated[Configuration, Depends(get_config)],
50
- logger: Annotated[Logger, Depends(get_logger)],
51
- dataset_service: Annotated[DatasetService, Depends(get_dataset_service)]) -> Dispatcher:
52
- return Dispatcher(vectorizer, config, logger, dataset_service)
53
 
54
-
55
- def get_acronym_service(db: Annotated[Session, Depends(get_db)]) -> AcronymService:
56
- return AcronymService(db)
 
 
 
 
57
 
58
 
59
- def get_document_service(dataset_service: Annotated[DatasetService, Depends(get_dataset_service)],
60
- config: Annotated[Configuration, Depends(get_config)],
61
- db: Annotated[sessionmaker, Depends(get_db)]) -> DocumentService:
 
 
62
  return DocumentService(dataset_service, config, db)
63
 
64
 
65
  def get_llm_config_service(db: Annotated[Session, Depends(get_db)]) -> LLMConfigService:
66
  return LLMConfigService(db)
67
 
68
- def get_llm_service(config: Annotated[Configuration, Depends(get_config)]) -> DeepInfraApi:
69
-
70
- llm_params = LlmParams(**{
71
- "url": config.llm_config.base_url,
72
- "model": config.llm_config.model,
73
- "tokenizer": config.llm_config.tokenizer,
74
- "type": "deepinfra",
75
- "default": True,
76
- "predict_params": None, #должны задаваться при каждом запросе
77
- "api_key": os.environ.get(config.llm_config.api_key_env),
78
- "context_length": 128000
79
- })
 
 
 
 
 
80
  return DeepInfraApi(params=llm_params)
81
 
 
82
  def get_llm_prompt_service(db: Annotated[Session, Depends(get_db)]) -> LlmPromptService:
83
- return LlmPromptService(db)
 
1
  import logging
 
2
  import os
3
+ from logging import Logger
4
+ from typing import Annotated
5
+
6
  from fastapi import Depends
7
+ from ntr_text_fragmentation import InjectionBuilder
8
+ from sqlalchemy.orm import Session, sessionmaker
9
 
10
  from common.configuration import Configuration
11
+ from common.db import session_factory
12
+ from components.dbo.chunk_repository import ChunkRepository
13
+ from components.embedding_extraction import EmbeddingExtractor
14
  from components.llm.common import LlmParams
15
  from components.llm.deepinfra_api import DeepInfraApi
16
  from components.services.dataset import DatasetService
 
 
17
  from components.services.document import DocumentService
18
+ from components.services.entity import EntityService
19
  from components.services.llm_config import LLMConfigService
 
 
 
 
20
  from components.services.llm_prompt import LlmPromptService
21
 
22
 
 
29
 
30
 
31
  def get_logger() -> Logger:
32
+ return logging.getLogger(__name__)
33
 
34
 
35
+ def get_embedding_extractor(
36
+ config: Annotated[Configuration, Depends(get_config)],
37
+ ) -> EmbeddingExtractor:
38
  return EmbeddingExtractor(
39
  config.db_config.faiss.model_embedding_path,
40
  config.db_config.faiss.device,
41
  )
42
 
43
 
44
+ def get_chunk_repository(db: Annotated[Session, Depends(get_db)]) -> ChunkRepository:
45
+ return ChunkRepository(db)
46
+
47
+
48
+ def get_injection_builder(
49
+ chunk_repository: Annotated[ChunkRepository, Depends(get_chunk_repository)],
50
+ ) -> InjectionBuilder:
51
+ return InjectionBuilder(chunk_repository)
52
+
53
+
54
+ def get_entity_service(
55
  vectorizer: Annotated[EmbeddingExtractor, Depends(get_embedding_extractor)],
56
+ chunk_repository: Annotated[ChunkRepository, Depends(get_chunk_repository)],
57
  config: Annotated[Configuration, Depends(get_config)],
58
+ ) -> EntityService:
59
+ """Получение сервиса для работы с сущностями через DI."""
60
+ return EntityService(vectorizer, chunk_repository, config)
61
 
 
 
 
 
 
62
 
63
+ def get_dataset_service(
64
+ entity_service: Annotated[EntityService, Depends(get_entity_service)],
65
+ config: Annotated[Configuration, Depends(get_config)],
66
+ db: Annotated[sessionmaker, Depends(get_db)],
67
+ ) -> DatasetService:
68
+ """Получение сервиса для работы с датасетами через DI."""
69
+ return DatasetService(entity_service, config, db)
70
 
71
 
72
+ def get_document_service(
73
+ dataset_service: Annotated[DatasetService, Depends(get_dataset_service)],
74
+ config: Annotated[Configuration, Depends(get_config)],
75
+ db: Annotated[sessionmaker, Depends(get_db)],
76
+ ) -> DocumentService:
77
  return DocumentService(dataset_service, config, db)
78
 
79
 
80
  def get_llm_config_service(db: Annotated[Session, Depends(get_db)]) -> LLMConfigService:
81
  return LLMConfigService(db)
82
 
83
+
84
+ def get_llm_service(
85
+ config: Annotated[Configuration, Depends(get_config)],
86
+ ) -> DeepInfraApi:
87
+
88
+ llm_params = LlmParams(
89
+ **{
90
+ "url": config.llm_config.base_url,
91
+ "model": config.llm_config.model,
92
+ "tokenizer": config.llm_config.tokenizer,
93
+ "type": "deepinfra",
94
+ "default": True,
95
+ "predict_params": None, # должны задаваться при каждом запросе
96
+ "api_key": os.environ.get(config.llm_config.api_key_env),
97
+ "context_length": 128000,
98
+ }
99
+ )
100
  return DeepInfraApi(params=llm_params)
101
 
102
+
103
  def get_llm_prompt_service(db: Annotated[Session, Depends(get_db)]) -> LlmPromptService:
104
+ return LlmPromptService(db)
components/dbo/chunk_repository.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from uuid import UUID
2
+
3
+ import numpy as np
4
+ from ntr_text_fragmentation import LinkerEntity
5
+ from ntr_text_fragmentation.integrations import SQLAlchemyEntityRepository
6
+ from sqlalchemy import and_, select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from components.dbo.models.entity import EntityModel
10
+
11
+
12
+ class ChunkRepository(SQLAlchemyEntityRepository):
13
+ def __init__(self, db: Session):
14
+ super().__init__(db)
15
+
16
+ def _entity_model_class(self):
17
+ return EntityModel
18
+
19
+ def _map_db_entity_to_linker_entity(self, db_entity: EntityModel):
20
+ """
21
+ Преобразует сущность из базы данных в LinkerEntity.
22
+
23
+ Args:
24
+ db_entity: Сущность из базы данных
25
+
26
+ Returns:
27
+ LinkerEntity
28
+ """
29
+ # Преобразуем строковые ID в UUID
30
+ entity = LinkerEntity(
31
+ id=UUID(db_entity.uuid), # Преобразуем строку в UUID
32
+ name=db_entity.name,
33
+ text=db_entity.text,
34
+ type=db_entity.entity_type,
35
+ in_search_text=db_entity.in_search_text,
36
+ metadata=db_entity.metadata_json,
37
+ source_id=UUID(db_entity.source_id) if db_entity.source_id else None, # Преобразуем строку в UUID
38
+ target_id=UUID(db_entity.target_id) if db_entity.target_id else None, # Преобразуем строку в UUID
39
+ number_in_relation=db_entity.number_in_relation,
40
+ )
41
+ return LinkerEntity.deserialize(entity)
42
+
43
+ def add_entities(
44
+ self,
45
+ entities: list[LinkerEntity],
46
+ dataset_id: int,
47
+ embeddings: dict[str, np.ndarray],
48
+ ):
49
+ """
50
+ Добавляет сущности в базу данных.
51
+
52
+ Args:
53
+ entities: Список сущностей для добавления
54
+ dataset_id: ID датасета
55
+ embeddings: Словарь эмбеддингов {entity_id: embedding}
56
+ """
57
+ with self.db() as session:
58
+ for entity in entities:
59
+ # Преобразуем UUID в строку для хранения в базе
60
+ entity_id = str(entity.id)
61
+
62
+ if entity_id in embeddings:
63
+ embedding = embeddings[entity_id]
64
+ else:
65
+ embedding = None
66
+
67
+ session.add(
68
+ EntityModel(
69
+ uuid=str(entity.id), # UUID в строку
70
+ name=entity.name,
71
+ text=entity.text,
72
+ entity_type=entity.type,
73
+ in_search_text=entity.in_search_text,
74
+ metadata_json=entity.metadata,
75
+ source_id=str(entity.source_id) if entity.source_id else None, # UUID в строку
76
+ target_id=str(entity.target_id) if entity.target_id else None, # UUID в строку
77
+ number_in_relation=entity.number_in_relation,
78
+ chunk_index=getattr(entity, "chunk_index", None), # Добавляем chunk_index
79
+ dataset_id=dataset_id,
80
+ embedding=embedding,
81
+ )
82
+ )
83
+
84
+ session.commit()
85
+
86
+ def get_searching_entities(
87
+ self,
88
+ dataset_id: int,
89
+ ) -> tuple[list[LinkerEntity], list[np.ndarray]]:
90
+ with self.db() as session:
91
+ models = (
92
+ session.query(EntityModel)
93
+ .filter(EntityModel.in_search_text is not None)
94
+ .filter(EntityModel.dataset_id == dataset_id)
95
+ .all()
96
+ )
97
+ return (
98
+ [self._map_db_entity_to_linker_entity(model) for model in models],
99
+ [model.embedding for model in models],
100
+ )
101
+
102
+ def get_chunks_by_ids(
103
+ self,
104
+ chunk_ids: list[str],
105
+ ) -> list[LinkerEntity]:
106
+ """
107
+ Получение чанков по их ID.
108
+
109
+ Args:
110
+ chunk_ids: Список ID чанков
111
+
112
+ Returns:
113
+ Список чанков
114
+ """
115
+ # Преобразуем все ID в строки для единообразия
116
+ str_chunk_ids = [str(chunk_id) for chunk_id in chunk_ids]
117
+
118
+ with self.db() as session:
119
+ models = (
120
+ session.query(EntityModel)
121
+ .filter(EntityModel.uuid.in_(str_chunk_ids))
122
+ .all()
123
+ )
124
+ return [self._map_db_entity_to_linker_entity(model) for model in models]
125
+
126
+ def get_entities_by_ids(self, entity_ids: list[UUID]) -> list[LinkerEntity]:
127
+ """
128
+ Получить сущности по списку идентификаторов.
129
+
130
+ Args:
131
+ entity_ids: Список идентифи��аторов сущностей
132
+
133
+ Returns:
134
+ Список сущностей, соответствующих указанным идентификаторам
135
+ """
136
+ if not entity_ids:
137
+ return []
138
+
139
+ # Преобразуем UUID в строки
140
+ str_entity_ids = [str(entity_id) for entity_id in entity_ids]
141
+
142
+ with self.db() as session:
143
+ entity_model = self._entity_model_class()
144
+ db_entities = session.execute(
145
+ select(entity_model).where(entity_model.uuid.in_(str_entity_ids))
146
+ ).scalars().all()
147
+
148
+ return [self._map_db_entity_to_linker_entity(entity) for entity in db_entities]
149
+
150
+ def get_neighboring_chunks(self, chunk_ids: list[UUID], max_distance: int = 1) -> list[LinkerEntity]:
151
+ """
152
+ Получить соседние чанки для указанных чанков.
153
+
154
+ Args:
155
+ chunk_ids: Список идентификаторов чанков
156
+ max_distance: Максимальное расстояние до соседа
157
+
158
+ Returns:
159
+ Список соседних чанков
160
+ """
161
+ if not chunk_ids:
162
+ return []
163
+
164
+ # Преобразуем UUID в строки
165
+ str_chunk_ids = [str(chunk_id) for chunk_id in chunk_ids]
166
+
167
+ with self.db() as session:
168
+ entity_model = self._entity_model_class()
169
+ result = []
170
+
171
+ # Сначала получаем указанные чанки, чтобы узнать их индексы и документы
172
+ chunks = session.execute(
173
+ select(entity_model).where(
174
+ and_(
175
+ entity_model.uuid.in_(str_chunk_ids),
176
+ entity_model.entity_type == "Chunk" # Используем entity_type вместо type
177
+ )
178
+ )
179
+ ).scalars().all()
180
+
181
+ if not chunks:
182
+ return []
183
+
184
+ # Находим документы для чанков через связи
185
+ doc_ids = set()
186
+ chunk_indices = {}
187
+
188
+ for chunk in chunks:
189
+ chunk_indices[chunk.uuid] = chunk.chunk_index
190
+
191
+ # Находим связь от документа к чанку
192
+ links = session.execute(
193
+ select(entity_model).where(
194
+ and_(
195
+ entity_model.target_id == chunk.uuid,
196
+ entity_model.name == "document_to_chunk"
197
+ )
198
+ )
199
+ ).scalars().all()
200
+
201
+ for link in links:
202
+ doc_ids.add(link.source_id)
203
+
204
+ if not doc_ids or not any(idx is not None for idx in chunk_indices.values()):
205
+ return []
206
+
207
+ # Для каждого документа находим все его чанки
208
+ for doc_id in doc_ids:
209
+ # Находим все связи от документа к чанкам
210
+ links = session.execute(
211
+ select(entity_model).where(
212
+ and_(
213
+ entity_model.source_id == doc_id,
214
+ entity_model.name == "document_to_chunk"
215
+ )
216
+ )
217
+ ).scalars().all()
218
+
219
+ doc_chunk_ids = [link.target_id for link in links]
220
+
221
+ # Получаем все чанки документа
222
+ doc_chunks = session.execute(
223
+ select(entity_model).where(
224
+ and_(
225
+ entity_model.uuid.in_(doc_chunk_ids),
226
+ entity_model.entity_type == "Chunk" # Используем entity_type вместо type
227
+ )
228
+ )
229
+ ).scalars().all()
230
+
231
+ # Для каждого чанка в документе проверяем, является ли он соседом
232
+ for doc_chunk in doc_chunks:
233
+ if doc_chunk.uuid in str_chunk_ids:
234
+ continue
235
+
236
+ if doc_chunk.chunk_index is None:
237
+ continue
238
+
239
+ # Проверяем, является ли чанк соседом какого-либо из исходных чанков
240
+ is_neighbor = False
241
+ for orig_chunk_id, orig_index in chunk_indices.items():
242
+ if orig_index is not None and abs(doc_chunk.chunk_index - orig_index) <= max_distance:
243
+ is_neighbor = True
244
+ break
245
+
246
+ if is_neighbor:
247
+ result.append(self._map_db_entity_to_linker_entity(doc_chunk))
248
+
249
+ return result
components/dbo/models/dataset.py CHANGED
@@ -23,4 +23,9 @@ class Dataset(Base):
23
  documents: Mapped[list["DatasetDocument"]] = relationship(
24
  "DatasetDocument", back_populates="dataset",
25
  cascade="all, delete-orphan"
26
- )
 
 
 
 
 
 
23
  documents: Mapped[list["DatasetDocument"]] = relationship(
24
  "DatasetDocument", back_populates="dataset",
25
  cascade="all, delete-orphan"
26
+ )
27
+
28
+ entities: Mapped[list["EntityModel"]] = relationship(
29
+ "EntityModel", back_populates="dataset",
30
+ cascade="all, delete-orphan"
31
+ )
components/dbo/models/entity.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import numpy as np
4
+ from sqlalchemy import ForeignKey, Integer, LargeBinary, String
5
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
6
+ from sqlalchemy.types import TypeDecorator
7
+
8
+ from components.dbo.models.base import Base
9
+
10
+
11
+ class JSONType(TypeDecorator):
12
+ """Тип для хранения JSON в SQLite."""
13
+
14
+ impl = String
15
+ cache_ok = True
16
+
17
+ def process_bind_param(self, value, dialect):
18
+ """Сохранение dict в JSON строку."""
19
+ if value is None:
20
+ return None
21
+ return json.dumps(value)
22
+
23
+ def process_result_value(self, value, dialect):
24
+ """Загрузка JSON строки в dict."""
25
+ if value is None:
26
+ return None
27
+ return json.loads(value)
28
+
29
+
30
+ class EmbeddingType(TypeDecorator):
31
+ """Тип для хранения эмбеддингов в SQLite."""
32
+
33
+ impl = LargeBinary
34
+ cache_ok = True
35
+
36
+ def process_bind_param(self, value, dialect):
37
+ """Сохранение numpy array в базу."""
38
+ if value is None:
39
+ return None
40
+ # Убеждаемся, что массив двумерный перед сохранением
41
+ value = np.asarray(value, dtype=np.float32)
42
+ if value.ndim == 1:
43
+ value = value.reshape(1, -1)
44
+ return value.tobytes()
45
+
46
+ def process_result_value(self, value, dialect):
47
+ """Загрузка из базы в numpy array."""
48
+ if value is None:
49
+ return None
50
+ return np.frombuffer(value, dtype=np.float32)
51
+
52
+
53
+ class EntityModel(Base):
54
+ """
55
+ SQLAlchemy модель для хранения сущностей.
56
+ """
57
+
58
+ __tablename__ = "entity"
59
+
60
+ uuid: Mapped[str] = mapped_column(String, unique=True)
61
+ name: Mapped[str] = mapped_column(String, nullable=False)
62
+ text: Mapped[str] = mapped_column(String, nullable=False)
63
+ in_search_text: Mapped[str] = mapped_column(String, nullable=True)
64
+ entity_type: Mapped[str] = mapped_column(String, nullable=False)
65
+
66
+ # Поля для связей (триплетный подход)
67
+ source_id: Mapped[str] = mapped_column(String, nullable=True)
68
+ target_id: Mapped[str] = mapped_column(String, nullable=True)
69
+ number_in_relation: Mapped[int] = mapped_column(Integer, nullable=True)
70
+
71
+ # Поле для индекса чанка в документе
72
+ chunk_index: Mapped[int] = mapped_column(Integer, nullable=True)
73
+
74
+ # JSON-поле для хранения метаданных
75
+ metadata_json: Mapped[dict] = mapped_column(JSONType, nullable=True)
76
+
77
+ embedding: Mapped[np.ndarray] = mapped_column(EmbeddingType, nullable=True)
78
+
79
+ dataset_id: Mapped[int] = mapped_column(Integer, ForeignKey("dataset.id"), nullable=False)
80
+
81
+ dataset: Mapped["Dataset"] = relationship( # type: ignore
82
+ "Dataset",
83
+ back_populates="entities",
84
+ cascade="all",
85
+ )
components/embedding_extraction.py CHANGED
@@ -5,10 +5,10 @@ import numpy as np
5
  import torch
6
  import torch.nn.functional as F
7
  from torch.utils.data import DataLoader
8
- from transformers import AutoModel, AutoTokenizer, BatchEncoding, XLMRobertaModel
9
- from transformers.modeling_outputs import (
10
- BaseModelOutputWithPoolingAndCrossAttentions as EncoderOutput,
11
- )
12
 
13
  logger = logging.getLogger(__name__)
14
 
@@ -41,8 +41,8 @@ class EmbeddingExtractor:
41
 
42
  self.device = device
43
  # Инициализация модели
44
- self.tokenizer = AutoTokenizer.from_pretrained(model_id)
45
- self.model: XLMRobertaModel = AutoModel.from_pretrained(model_id).to(
46
  self.device
47
  )
48
  self.model.eval()
@@ -122,7 +122,6 @@ class EmbeddingExtractor:
122
 
123
  return embedding.cpu().numpy()
124
 
125
- # TODO: В будущем стоит объединить vectorize и query_embed_extraction
126
  def vectorize(
127
  self,
128
  texts: list[str] | str,
@@ -162,7 +161,11 @@ class EmbeddingExtractor:
162
 
163
  logger.info('Vectorized all %d batches', len(embeddings))
164
 
165
- return torch.cat(embeddings).numpy()
 
 
 
 
166
 
167
  @torch.no_grad()
168
  def _vectorize_batch(
 
5
  import torch
6
  import torch.nn.functional as F
7
  from torch.utils.data import DataLoader
8
+ from transformers import (AutoModel, AutoTokenizer, BatchEncoding,
9
+ XLMRobertaModel)
10
+ from transformers.modeling_outputs import \
11
+ BaseModelOutputWithPoolingAndCrossAttentions as EncoderOutput
12
 
13
  logger = logging.getLogger(__name__)
14
 
 
41
 
42
  self.device = device
43
  # Инициализация модели
44
+ self.tokenizer = AutoTokenizer.from_pretrained(model_id, local_files_only=True)
45
+ self.model: XLMRobertaModel = AutoModel.from_pretrained(model_id, local_files_only=True).to(
46
  self.device
47
  )
48
  self.model.eval()
 
122
 
123
  return embedding.cpu().numpy()
124
 
 
125
  def vectorize(
126
  self,
127
  texts: list[str] | str,
 
161
 
162
  logger.info('Vectorized all %d batches', len(embeddings))
163
 
164
+ result = torch.cat(embeddings).numpy()
165
+ # Всегда возвращаем двумерный массив
166
+ if result.ndim == 1:
167
+ result = result.reshape(1, -1)
168
+ return result
169
 
170
  @torch.no_grad()
171
  def _vectorize_batch(
components/nmd/faiss_vector_search.py CHANGED
@@ -1,12 +1,10 @@
1
  import logging
2
- from typing import List
3
- import numpy as np
4
- import pandas as pd
5
  import faiss
 
6
 
7
- from common.constants import COLUMN_EMBEDDING
8
- from common.constants import DO_NORMALIZATION
9
  from common.configuration import DataBaseConfiguration
 
10
  from components.embedding_extraction import EmbeddingExtractor
11
 
12
  logger = logging.getLogger(__name__)
@@ -14,7 +12,10 @@ logger = logging.getLogger(__name__)
14
 
15
  class FaissVectorSearch:
16
  def __init__(
17
- self, model: EmbeddingExtractor, df: pd.DataFrame, config: DataBaseConfiguration
 
 
 
18
  ):
19
  self.model = model
20
  self.config = config
@@ -23,26 +24,36 @@ class FaissVectorSearch:
23
  self.k_neighbors = config.ranker.k_neighbors
24
  else:
25
  self.k_neighbors = config.search.vector_search.k_neighbors
26
- self.__create_index(df)
 
27
 
28
- def __create_index(self, df: pd.DataFrame):
29
  """Load the metadata file."""
30
- if len(df) == 0:
31
  self.index = None
32
  return
33
- df = df.where(pd.notna(df), None)
34
- embeddings = np.array(df[COLUMN_EMBEDDING].tolist())
35
  dim = embeddings.shape[1]
36
- self.index = faiss.IndexFlatL2(dim)
37
  self.index.add(embeddings)
38
 
39
  def search_vectors(self, query: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
40
  """
41
  Поиск векторов в индексе.
 
 
 
 
 
 
 
 
 
42
  """
43
  logger.info(f"Searching vectors in index for query: {query}")
44
  if self.index is None:
45
  return (np.array([]), np.array([]), np.array([]))
46
  query_embeds = self.model.query_embed_extraction(query, DO_NORMALIZATION)
47
- scores, indexes = self.index.search(query_embeds, self.k_neighbors)
48
- return query_embeds[0], scores[0], indexes[0]
 
 
1
  import logging
2
+
 
 
3
  import faiss
4
+ import numpy as np
5
 
 
 
6
  from common.configuration import DataBaseConfiguration
7
+ from common.constants import DO_NORMALIZATION
8
  from components.embedding_extraction import EmbeddingExtractor
9
 
10
  logger = logging.getLogger(__name__)
 
12
 
13
  class FaissVectorSearch:
14
  def __init__(
15
+ self,
16
+ model: EmbeddingExtractor,
17
+ ids_to_embeddings: dict[str, np.ndarray],
18
+ config: DataBaseConfiguration,
19
  ):
20
  self.model = model
21
  self.config = config
 
24
  self.k_neighbors = config.ranker.k_neighbors
25
  else:
26
  self.k_neighbors = config.search.vector_search.k_neighbors
27
+ self.index_to_id = {i: id_ for i, id_ in enumerate(ids_to_embeddings.keys())}
28
+ self.__create_index(ids_to_embeddings)
29
 
30
+ def __create_index(self, ids_to_embeddings: dict[str, np.ndarray]):
31
  """Load the metadata file."""
32
+ if len(ids_to_embeddings) == 0:
33
  self.index = None
34
  return
35
+ embeddings = np.array(list(ids_to_embeddings.values()))
 
36
  dim = embeddings.shape[1]
37
+ self.index = faiss.IndexFlatIP(dim)
38
  self.index.add(embeddings)
39
 
40
  def search_vectors(self, query: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
41
  """
42
  Поиск векторов в индексе.
43
+
44
+ Args:
45
+ query: Строка, запрос для поиска.
46
+
47
+ Returns:
48
+ tuple[np.ndarray, np.ndarray, np.ndarray]: Кортеж из трех массивов:
49
+ - np.ndarray: Вектор запроса (1, embedding_size)
50
+ - np.ndarray: Оценки косинусного сходства (чем больше, тем лучше)
51
+ - np.ndarray: Идентификаторы найденных векторов
52
  """
53
  logger.info(f"Searching vectors in index for query: {query}")
54
  if self.index is None:
55
  return (np.array([]), np.array([]), np.array([]))
56
  query_embeds = self.model.query_embed_extraction(query, DO_NORMALIZATION)
57
+ similarities, indexes = self.index.search(query_embeds, self.k_neighbors)
58
+ ids = [self.index_to_id[index] for index in indexes[0]]
59
+ return query_embeds, similarities[0], np.array(ids)
components/services/dataset.py CHANGED
@@ -4,33 +4,27 @@ import os
4
  import shutil
5
  import zipfile
6
  from datetime import datetime
7
- from multiprocessing import Process
8
  from pathlib import Path
9
- from typing import Optional
10
- from threading import Lock
11
 
12
  import pandas as pd
13
  import torch
14
  from fastapi import BackgroundTasks, HTTPException, UploadFile
 
 
15
 
16
  from common.common import get_source_format
17
  from common.configuration import Configuration
18
- from components.embedding_extraction import EmbeddingExtractor
19
- from components.parser.features.documents_dataset import DocumentsDataset
20
- from components.parser.pipeline import DatasetCreationPipeline
21
- from components.parser.xml.structures import ParsedXML
22
- from components.parser.xml.xml_parser import XMLParser
23
- from sqlalchemy.orm import Session
24
- from components.dbo.models.acronym import Acronym
25
  from components.dbo.models.dataset import Dataset
26
  from components.dbo.models.dataset_document import DatasetDocument
27
  from components.dbo.models.document import Document
 
28
  from schemas.dataset import Dataset as DatasetSchema
29
  from schemas.dataset import DatasetExpanded as DatasetExpandedSchema
30
  from schemas.dataset import DatasetProcessing
31
  from schemas.dataset import DocumentsPage as DocumentsPageSchema
32
  from schemas.dataset import SortQueryList
33
  from schemas.document import Document as DocumentSchema
 
34
  logger = logging.getLogger(__name__)
35
 
36
 
@@ -38,24 +32,31 @@ class DatasetService:
38
  """
39
  Сервис для работы с датасетами.
40
  """
41
-
42
  def __init__(
43
- self,
44
- vectorizer: EmbeddingExtractor,
45
  config: Configuration,
46
- db: Session
47
  ) -> None:
 
 
 
 
 
 
 
 
48
  logger.info("DatasetService initializing")
49
  self.db = db
50
  self.config = config
51
- self.parser = XMLParser()
52
- self.vectorizer = vectorizer
53
  self.regulations_path = Path(config.db_config.files.regulations_path)
54
  self.documents_path = Path(config.db_config.files.documents_path)
55
- self.tmp_path= Path(os.environ.get("APP_TMP_PATH", '.'))
56
  logger.info("DatasetService initialized")
57
 
58
-
59
  def get_dataset(
60
  self,
61
  dataset_id: int,
@@ -83,9 +84,6 @@ class DatasetService:
83
  session.query(Document)
84
  .join(DatasetDocument, DatasetDocument.document_id == Document.id)
85
  .filter(DatasetDocument.dataset_id == dataset_id)
86
- .filter(
87
- Document.status.in_(['Актуальный', 'Требует актуализации', 'Упразднён'])
88
- )
89
  .filter(Document.title.like(f'%{search}%'))
90
  )
91
 
@@ -98,7 +96,9 @@ class DatasetService:
98
  .join(DatasetDocument, DatasetDocument.document_id == Document.id)
99
  .filter(DatasetDocument.dataset_id == dataset_id)
100
  .filter(
101
- Document.status.in_(['Актуальный', 'Требует актуализации', 'Упразднён'])
 
 
102
  )
103
  .filter(Document.title.like(f'%{search}%'))
104
  .count()
@@ -142,7 +142,7 @@ class DatasetService:
142
  name=dataset.name,
143
  isDraft=dataset.is_draft,
144
  isActive=dataset.is_active,
145
- dateCreated=dataset.date_created
146
  )
147
  for dataset in datasets
148
  ]
@@ -198,8 +198,10 @@ class DatasetService:
198
  self.raise_if_processing()
199
 
200
  with self.db() as session:
201
- dataset: Dataset = session.query(Dataset).filter(Dataset.id == dataset_id).first()
202
-
 
 
203
  if not dataset:
204
  raise HTTPException(status_code=404, detail='Dataset not found')
205
 
@@ -222,36 +224,42 @@ class DatasetService:
222
  """
223
  try:
224
  with self.db() as session:
225
- dataset = session.query(Dataset).filter(Dataset.id == dataset_id).first()
 
 
226
  if not dataset:
227
- raise HTTPException(status_code=404, detail=f"Dataset with id {dataset_id} not found")
228
-
229
- active_dataset = session.query(Dataset).filter(Dataset.is_active == True).first()
230
-
231
- self.apply_draft(dataset, session)
 
 
 
 
 
232
  dataset.is_draft = False
233
  dataset.is_active = True
234
  if active_dataset:
235
  active_dataset.is_active = False
236
-
237
  session.commit()
238
  except Exception as e:
239
  logger.error(f"Error applying draft: {e}")
240
  raise
241
-
242
-
243
- def activate_dataset(self, dataset_id: int, background_tasks: BackgroundTasks) -> DatasetExpandedSchema:
 
244
  """
245
  Активировать датасет в фоновой задаче.
246
  """
247
-
248
  logger.info(f"Activating dataset {dataset_id}")
249
  self.raise_if_processing()
250
 
251
  with self.db() as session:
252
- dataset = (
253
- session.query(Dataset).filter(Dataset.id == dataset_id).first()
254
- )
255
  active_dataset = session.query(Dataset).filter(Dataset.is_active).first()
256
  if not dataset:
257
  raise HTTPException(status_code=404, detail='Dataset not found')
@@ -329,7 +337,7 @@ class DatasetService:
329
 
330
  dataset = self.create_dataset_from_directory(
331
  is_default=False,
332
- directory_with_xmls=file_location.parent,
333
  directory_with_ready_dataset=None,
334
  )
335
 
@@ -341,10 +349,12 @@ class DatasetService:
341
  def apply_draft(
342
  self,
343
  dataset: Dataset,
344
- session,
345
  ) -> None:
346
  """
347
  Сохранить черновик как полноценный датасет.
 
 
 
348
  """
349
  torch.set_num_threads(1)
350
  logger.info(f"Applying draft dataset {dataset.id}")
@@ -363,9 +373,7 @@ class DatasetService:
363
  if current % log_step != 0:
364
  return
365
  if (total > 10) and (current % (total // 10) == 0):
366
- logger.info(
367
- f"Processing dataset {dataset.id}: {current}/{total}"
368
- )
369
  with open(TMP_PATH, 'w', encoding='utf-8') as f:
370
  json.dump(
371
  {
@@ -381,34 +389,25 @@ class DatasetService:
381
  document_ids = [
382
  doc_dataset_link.document_id for doc_dataset_link in dataset.documents
383
  ]
384
- document_formats = [
385
- doc_dataset_link.document.source_format
386
- for doc_dataset_link in dataset.documents
387
- ]
388
-
389
- prepared_abbreviations = (
390
- session.query(Acronym).filter(Acronym.document_id.in_(document_ids)).all()
391
- )
392
-
393
- pipeline = DatasetCreationPipeline(
394
- dataset_id=dataset.id,
395
- vectorizer=self.vectorizer,
396
- prepared_abbreviations=prepared_abbreviations,
397
- document_ids=document_ids,
398
- document_formats=document_formats,
399
- datasets_path=self.regulations_path,
400
- documents_path=self.documents_path,
401
- save_intermediate_files=True,
402
- )
403
- progress_callback(0, 1000)
404
-
405
- try:
406
- pipeline.run(progress_callback)
407
- except Exception as e:
408
- logger.error(f"Error running pipeline: {e}")
409
- raise HTTPException(status_code=500, detail=str(e))
410
- finally:
411
- TMP_PATH.unlink()
412
 
413
  def raise_if_processing(self) -> None:
414
  """
@@ -423,7 +422,7 @@ class DatasetService:
423
  def create_dataset_from_directory(
424
  self,
425
  is_default: bool,
426
- directory_with_xmls: Path,
427
  directory_with_ready_dataset: Path | None = None,
428
  ) -> Dataset:
429
  """
@@ -438,7 +437,7 @@ class DatasetService:
438
  Dataset: Созданный датасет.
439
  """
440
  logger.info(
441
- f"Creating {'default' if is_default else 'new'} dataset from directory {directory_with_xmls}"
442
  )
443
  with self.db() as session:
444
  documents = []
@@ -453,9 +452,9 @@ class DatasetService:
453
  )
454
  session.add(dataset)
455
 
456
- for subpath in self._get_recursive_dirlist(directory_with_xmls):
457
  document, relation = self._create_document(
458
- directory_with_xmls, subpath, dataset
459
  )
460
  if document is None:
461
  continue
@@ -484,7 +483,8 @@ class DatasetService:
484
  old_filename = document.filename
485
  new_filename = '{}.{}'.format(document.id, document.source_format)
486
  shutil.copy(
487
- directory_with_xmls / old_filename, self.documents_path / new_filename
 
488
  )
489
  document.filename = new_filename
490
 
@@ -495,16 +495,8 @@ class DatasetService:
495
 
496
  dataset_id = dataset.id
497
 
498
-
499
  logger.info(f"Dataset {dataset_id} created")
500
 
501
- df = self.dataset_to_pandas(dataset_id)
502
-
503
- (self.regulations_path / str(dataset_id)).mkdir(parents=True, exist_ok=True)
504
- df.to_csv(
505
- self.regulations_path / str(dataset_id) / 'documents.csv', index=False
506
- )
507
-
508
  return dataset
509
 
510
  def create_empty_dataset(self, is_default: bool) -> Dataset:
@@ -526,20 +518,6 @@ class DatasetService:
526
  session.commit()
527
  session.refresh(dataset)
528
 
529
- self.documents_path.mkdir(exist_ok=True)
530
-
531
- dataset_id = dataset.id
532
-
533
-
534
- folder = self.regulations_path / str(dataset_id)
535
- folder.mkdir(parents=True, exist_ok=True)
536
-
537
- pickle_creator = DocumentsDataset([])
538
- pickle_creator.to_pickle(folder / 'dataset.pkl')
539
-
540
- df = self.dataset_to_pandas(dataset_id)
541
- df.to_csv(folder / 'documents.csv', index=False)
542
-
543
  return dataset
544
 
545
  @staticmethod
@@ -553,10 +531,10 @@ class DatasetService:
553
  Returns:
554
  list[Path]: Список путей к xml-файлам относительно path.
555
  """
556
- xml_files = set() #set для отбрасывания неуникальных путей
557
  for ext in ('*.xml', '*.XML', '*.docx', '*.DOCX'):
558
  xml_files.update(path.glob(f'**/{ext}'))
559
-
560
  return [p.relative_to(path) for p in xml_files]
561
 
562
  def _create_document(
@@ -580,19 +558,19 @@ class DatasetService:
580
 
581
  try:
582
  source_format = get_source_format(str(subpath))
583
- parsed_xml: ParsedXML | None = self.parser.parse(
584
- documents_path / subpath, include_content=False
585
  )
586
 
587
- if not parsed_xml:
588
  logger.warning(f"Failed to parse file: {subpath}")
589
  return None, None
590
 
591
  document = Document(
592
  filename=str(subpath),
593
- title=parsed_xml.name,
594
- status=parsed_xml.status,
595
- owner=parsed_xml.owner,
596
  source_format=source_format,
597
  )
598
  relation = DatasetDocument(
@@ -606,36 +584,6 @@ class DatasetService:
606
  logger.error(f"Error creating document from {subpath}: {e}")
607
  return None, None
608
 
609
- def dataset_to_pandas(self, dataset_id: int) -> pd.DataFrame:
610
- """
611
- Преобразовать датасет в pandas DataFrame.
612
- """
613
- with self.db() as session:
614
- links = (
615
- session.query(DatasetDocument)
616
- .filter(DatasetDocument.dataset_id == dataset_id)
617
- .all()
618
- )
619
- documents = (
620
- session.query(Document)
621
- .filter(Document.id.in_([link.document_id for link in links]))
622
- .all()
623
- )
624
-
625
- return pd.DataFrame(
626
- [
627
- {
628
- 'id': document.id,
629
- 'filename': document.filename,
630
- 'title': document.title,
631
- 'status': document.status,
632
- 'owner': document.owner,
633
- }
634
- for document in documents
635
- ],
636
- columns=['id', 'filename', 'title', 'status', 'owner'],
637
- )
638
-
639
  def get_current_dataset(self) -> Dataset | None:
640
  with self.db() as session:
641
  print(session)
 
4
  import shutil
5
  import zipfile
6
  from datetime import datetime
 
7
  from pathlib import Path
 
 
8
 
9
  import pandas as pd
10
  import torch
11
  from fastapi import BackgroundTasks, HTTPException, UploadFile
12
+ from ntr_fileparser import ParsedDocument, UniversalParser
13
+ from sqlalchemy.orm import Session
14
 
15
  from common.common import get_source_format
16
  from common.configuration import Configuration
 
 
 
 
 
 
 
17
  from components.dbo.models.dataset import Dataset
18
  from components.dbo.models.dataset_document import DatasetDocument
19
  from components.dbo.models.document import Document
20
+ from components.services.entity import EntityService
21
  from schemas.dataset import Dataset as DatasetSchema
22
  from schemas.dataset import DatasetExpanded as DatasetExpandedSchema
23
  from schemas.dataset import DatasetProcessing
24
  from schemas.dataset import DocumentsPage as DocumentsPageSchema
25
  from schemas.dataset import SortQueryList
26
  from schemas.document import Document as DocumentSchema
27
+
28
  logger = logging.getLogger(__name__)
29
 
30
 
 
32
  """
33
  Сервис для работы с датасетами.
34
  """
35
+
36
  def __init__(
37
+ self,
38
+ entity_service: EntityService,
39
  config: Configuration,
40
+ db: Session,
41
  ) -> None:
42
+ """
43
+ Инициализация сервиса.
44
+
45
+ Args:
46
+ entity_service: Сервис для работы с сущностями
47
+ config: Конфигурация приложения
48
+ db: SQLAlchemy сессия
49
+ """
50
  logger.info("DatasetService initializing")
51
  self.db = db
52
  self.config = config
53
+ self.parser = UniversalParser()
54
+ self.entity_service = entity_service
55
  self.regulations_path = Path(config.db_config.files.regulations_path)
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(
61
  self,
62
  dataset_id: int,
 
84
  session.query(Document)
85
  .join(DatasetDocument, DatasetDocument.document_id == Document.id)
86
  .filter(DatasetDocument.dataset_id == dataset_id)
 
 
 
87
  .filter(Document.title.like(f'%{search}%'))
88
  )
89
 
 
96
  .join(DatasetDocument, DatasetDocument.document_id == Document.id)
97
  .filter(DatasetDocument.dataset_id == dataset_id)
98
  .filter(
99
+ Document.status.in_(
100
+ ['Актуальный', 'Требует актуализации', 'Упразднён']
101
+ )
102
  )
103
  .filter(Document.title.like(f'%{search}%'))
104
  .count()
 
142
  name=dataset.name,
143
  isDraft=dataset.is_draft,
144
  isActive=dataset.is_active,
145
+ dateCreated=dataset.date_created,
146
  )
147
  for dataset in datasets
148
  ]
 
198
  self.raise_if_processing()
199
 
200
  with self.db() as session:
201
+ dataset: Dataset = (
202
+ session.query(Dataset).filter(Dataset.id == dataset_id).first()
203
+ )
204
+
205
  if not dataset:
206
  raise HTTPException(status_code=404, detail='Dataset not found')
207
 
 
224
  """
225
  try:
226
  with self.db() as session:
227
+ dataset = (
228
+ session.query(Dataset).filter(Dataset.id == dataset_id).first()
229
+ )
230
  if not dataset:
231
+ raise HTTPException(
232
+ status_code=404,
233
+ detail=f"Dataset with id {dataset_id} not found",
234
+ )
235
+
236
+ active_dataset = (
237
+ session.query(Dataset).filter(Dataset.is_active == True).first()
238
+ )
239
+
240
+ self.apply_draft(dataset)
241
  dataset.is_draft = False
242
  dataset.is_active = True
243
  if active_dataset:
244
  active_dataset.is_active = False
245
+
246
  session.commit()
247
  except Exception as e:
248
  logger.error(f"Error applying draft: {e}")
249
  raise
250
+
251
+ def activate_dataset(
252
+ self, dataset_id: int, background_tasks: BackgroundTasks
253
+ ) -> DatasetExpandedSchema:
254
  """
255
  Активировать датасет в фоновой задаче.
256
  """
257
+
258
  logger.info(f"Activating dataset {dataset_id}")
259
  self.raise_if_processing()
260
 
261
  with self.db() as session:
262
+ dataset = session.query(Dataset).filter(Dataset.id == dataset_id).first()
 
 
263
  active_dataset = session.query(Dataset).filter(Dataset.is_active).first()
264
  if not dataset:
265
  raise HTTPException(status_code=404, detail='Dataset not found')
 
337
 
338
  dataset = self.create_dataset_from_directory(
339
  is_default=False,
340
+ directory_with_documents=file_location.parent,
341
  directory_with_ready_dataset=None,
342
  )
343
 
 
349
  def apply_draft(
350
  self,
351
  dataset: Dataset,
 
352
  ) -> None:
353
  """
354
  Сохранить черновик как полноценный датасет.
355
+
356
+ Args:
357
+ dataset: Датасет для применения
358
  """
359
  torch.set_num_threads(1)
360
  logger.info(f"Applying draft dataset {dataset.id}")
 
373
  if current % log_step != 0:
374
  return
375
  if (total > 10) and (current % (total // 10) == 0):
376
+ logger.info(f"Processing dataset {dataset.id}: {current}/{total}")
 
 
377
  with open(TMP_PATH, 'w', encoding='utf-8') as f:
378
  json.dump(
379
  {
 
389
  document_ids = [
390
  doc_dataset_link.document_id for doc_dataset_link in dataset.documents
391
  ]
392
+
393
+ for document_id in document_ids:
394
+ path = self.documents_path / f'{document_id}.DOCX'
395
+ parsed = self.parser.parse_by_path(str(path))
396
+ if parsed is None:
397
+ logger.warning(f"Failed to parse document {document_id}")
398
+ continue
399
+
400
+ # Используем EntityService для обработки документа с callback
401
+ self.entity_service.process_document(
402
+ parsed,
403
+ dataset.id,
404
+ progress_callback=progress_callback,
405
+ words_per_chunk=50,
406
+ overlap_words=25,
407
+ respect_sentence_boundaries=True,
408
+ )
409
+
410
+ TMP_PATH.unlink()
 
 
 
 
 
 
 
 
 
411
 
412
  def raise_if_processing(self) -> None:
413
  """
 
422
  def create_dataset_from_directory(
423
  self,
424
  is_default: bool,
425
+ directory_with_documents: Path,
426
  directory_with_ready_dataset: Path | None = None,
427
  ) -> Dataset:
428
  """
 
437
  Dataset: Созданный датасет.
438
  """
439
  logger.info(
440
+ f"Creating {'default' if is_default else 'new'} dataset from directory {directory_with_documents}"
441
  )
442
  with self.db() as session:
443
  documents = []
 
452
  )
453
  session.add(dataset)
454
 
455
+ for subpath in self._get_recursive_dirlist(directory_with_documents):
456
  document, relation = self._create_document(
457
+ directory_with_documents, subpath, dataset
458
  )
459
  if document is None:
460
  continue
 
483
  old_filename = document.filename
484
  new_filename = '{}.{}'.format(document.id, document.source_format)
485
  shutil.copy(
486
+ directory_with_documents / old_filename,
487
+ self.documents_path / new_filename,
488
  )
489
  document.filename = new_filename
490
 
 
495
 
496
  dataset_id = dataset.id
497
 
 
498
  logger.info(f"Dataset {dataset_id} created")
499
 
 
 
 
 
 
 
 
500
  return dataset
501
 
502
  def create_empty_dataset(self, is_default: bool) -> Dataset:
 
518
  session.commit()
519
  session.refresh(dataset)
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  return dataset
522
 
523
  @staticmethod
 
531
  Returns:
532
  list[Path]: Список путей к xml-файлам относительно path.
533
  """
534
+ xml_files = set() # set для отбрасывания неуникальных путей
535
  for ext in ('*.xml', '*.XML', '*.docx', '*.DOCX'):
536
  xml_files.update(path.glob(f'**/{ext}'))
537
+
538
  return [p.relative_to(path) for p in xml_files]
539
 
540
  def _create_document(
 
558
 
559
  try:
560
  source_format = get_source_format(str(subpath))
561
+ parsed: ParsedDocument | None = self.parser.parse_by_path(
562
+ str(documents_path / subpath)
563
  )
564
 
565
+ if not parsed:
566
  logger.warning(f"Failed to parse file: {subpath}")
567
  return None, None
568
 
569
  document = Document(
570
  filename=str(subpath),
571
+ title=parsed.name,
572
+ status=parsed.meta.status,
573
+ owner=parsed.meta.owner,
574
  source_format=source_format,
575
  )
576
  relation = DatasetDocument(
 
584
  logger.error(f"Error creating document from {subpath}: {e}")
585
  return None, None
586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  def get_current_dataset(self) -> Dataset | None:
588
  with self.db() as session:
589
  print(session)
components/services/document.py CHANGED
@@ -4,19 +4,18 @@ import shutil
4
  from pathlib import Path
5
 
6
  from fastapi import HTTPException, UploadFile
 
7
 
8
  from sqlalchemy.orm import Session
9
  from common.common import get_source_format
10
  from common.configuration import Configuration
11
  from common.constants import PROCESSING_FORMATS
12
- from components.parser.xml.xml_parser import XMLParser
13
  from components.dbo.models.dataset import Dataset
14
  from components.dbo.models.dataset_document import DatasetDocument
15
  from components.dbo.models.document import Document
16
  from schemas.document import Document as DocumentSchema
17
  from schemas.document import DocumentDownload
18
  from components.services.dataset import DatasetService
19
-
20
  logger = logging.getLogger(__name__)
21
 
22
 
@@ -34,7 +33,7 @@ class DocumentService:
34
  logger.info("Initializing DocumentService")
35
  self.db = db
36
  self.dataset_service = dataset_service
37
- self.xml_parser = XMLParser()
38
  self.documents_path = Path(config.db_config.files.documents_path)
39
 
40
  def get_document(
@@ -101,10 +100,10 @@ class DocumentService:
101
  logger.info(f"Source format: {source_format}")
102
 
103
  try:
104
- parsed = self.xml_parser.parse(file_location, include_content=False)
105
  except Exception:
106
  raise HTTPException(
107
- status_code=400, detail="Invalid XML file, service can't parse it"
108
  )
109
 
110
  with self.db() as session:
@@ -118,9 +117,10 @@ class DocumentService:
118
  raise HTTPException(status_code=403, detail='Dataset is not draft')
119
 
120
  document = Document(
 
121
  title=parsed.name,
122
- owner=parsed.owner,
123
- status=parsed.status,
124
  source_format=source_format,
125
  )
126
 
@@ -129,21 +129,21 @@ class DocumentService:
129
  session.add(document)
130
  session.flush()
131
 
132
- logger.info(f"Document ID: {document.document_id}")
133
 
134
  link = DatasetDocument(
135
  dataset_id=dataset_id,
136
- document_id=document.document_id,
137
  )
138
  session.add(link)
139
 
140
  if source_format in PROCESSING_FORMATS:
141
  logger.info(
142
- f"Moving file to: {self.documents_path / f'{document.document_id}.{source_format}'}"
143
  )
144
  shutil.move(
145
  file_location,
146
- self.documents_path / f'{document.document_id}.{source_format}',
147
  )
148
  else:
149
  logger.error(f"Unknown source format: {source_format}")
@@ -156,7 +156,7 @@ class DocumentService:
156
  session.refresh(document)
157
 
158
  result = DocumentSchema(
159
- id=document.document_id,
160
  name=document.title,
161
  owner=document.owner,
162
  status=document.status,
 
4
  from pathlib import Path
5
 
6
  from fastapi import HTTPException, UploadFile
7
+ from ntr_fileparser import UniversalParser
8
 
9
  from sqlalchemy.orm import Session
10
  from common.common import get_source_format
11
  from common.configuration import Configuration
12
  from common.constants import PROCESSING_FORMATS
 
13
  from components.dbo.models.dataset import Dataset
14
  from components.dbo.models.dataset_document import DatasetDocument
15
  from components.dbo.models.document import Document
16
  from schemas.document import Document as DocumentSchema
17
  from schemas.document import DocumentDownload
18
  from components.services.dataset import DatasetService
 
19
  logger = logging.getLogger(__name__)
20
 
21
 
 
33
  logger.info("Initializing DocumentService")
34
  self.db = db
35
  self.dataset_service = dataset_service
36
+ self.parser = UniversalParser()
37
  self.documents_path = Path(config.db_config.files.documents_path)
38
 
39
  def get_document(
 
100
  logger.info(f"Source format: {source_format}")
101
 
102
  try:
103
+ parsed = self.parser.parse_by_path(str(file_location))
104
  except Exception:
105
  raise HTTPException(
106
+ status_code=400, detail="Invalid file, service can't parse it"
107
  )
108
 
109
  with self.db() as session:
 
117
  raise HTTPException(status_code=403, detail='Dataset is not draft')
118
 
119
  document = Document(
120
+ filename=file.filename,
121
  title=parsed.name,
122
+ owner=parsed.meta.owner,
123
+ status=parsed.meta.status,
124
  source_format=source_format,
125
  )
126
 
 
129
  session.add(document)
130
  session.flush()
131
 
132
+ logger.info(f"Document ID: {document.id}")
133
 
134
  link = DatasetDocument(
135
  dataset_id=dataset_id,
136
+ document_id=document.id,
137
  )
138
  session.add(link)
139
 
140
  if source_format in PROCESSING_FORMATS:
141
  logger.info(
142
+ f"Moving file to: {self.documents_path / f'{document.id}.{source_format}'}"
143
  )
144
  shutil.move(
145
  file_location,
146
+ self.documents_path / f'{document.id}.{source_format}',
147
  )
148
  else:
149
  logger.error(f"Unknown source format: {source_format}")
 
156
  session.refresh(document)
157
 
158
  result = DocumentSchema(
159
+ id=document.id,
160
  name=document.title,
161
  owner=document.owner,
162
  status=document.status,
components/services/entity.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Callable, Optional
3
+ from uuid import UUID
4
+
5
+ import numpy as np
6
+ from ntr_fileparser import ParsedDocument
7
+ from ntr_text_fragmentation import Destructurer, InjectionBuilder, LinkerEntity
8
+
9
+ from common.configuration import Configuration
10
+ from components.dbo.chunk_repository import ChunkRepository
11
+ from components.embedding_extraction import EmbeddingExtractor
12
+ from components.nmd.faiss_vector_search import FaissVectorSearch
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class EntityService:
18
+ """
19
+ Сервис для работы с сущностями.
20
+ Объединяет функциональность chunk_repository, destructurer, injection_builder и faiss_vector_search.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ vectorizer: EmbeddingExtractor,
26
+ chunk_repository: ChunkRepository,
27
+ config: Configuration,
28
+ ) -> None:
29
+ """
30
+ Инициализация сервиса.
31
+
32
+ Args:
33
+ vectorizer: Модель для извлечения эмбеддингов
34
+ chunk_repository: Репозиторий для работы с чанками
35
+ config: Конфигурация приложения
36
+ """
37
+ self.vectorizer = vectorizer
38
+ self.config = config
39
+ self.chunk_repository = chunk_repository
40
+ self.faiss_search = None # Инициализируется при необходимости
41
+ self.current_dataset_id = None # Текущий dataset_id
42
+
43
+ def _ensure_faiss_initialized(self, dataset_id: int) -> None:
44
+ """
45
+ Проверяет и при необходимости инициализирует или обновляет FAISS индекс.
46
+
47
+ Args:
48
+ dataset_id: ID датасета для инициализации
49
+ """
50
+ # Если индекс не инициализирован или датасет изменился
51
+ if self.faiss_search is None or self.current_dataset_id != dataset_id:
52
+ logger.info(f'Initializing FAISS for dataset {dataset_id}')
53
+ entities, embeddings = self.chunk_repository.get_searching_entities(dataset_id)
54
+ if entities:
55
+ # Создаем словарь только из не-None эмбеддингов
56
+ embeddings_dict = {
57
+ str(entity.id): embedding # Преобразуем UUID в строку для ключа
58
+ for entity, embedding in zip(entities, embeddings)
59
+ if embedding is not None
60
+ }
61
+ if embeddings_dict: # Проверяем, что есть хотя бы один эмбеддинг
62
+ self.faiss_search = FaissVectorSearch(
63
+ self.vectorizer,
64
+ embeddings_dict,
65
+ self.config.db_config,
66
+ )
67
+ self.current_dataset_id = dataset_id
68
+ logger.info(f'FAISS initialized for dataset {dataset_id} with {len(embeddings_dict)} embeddings')
69
+ else:
70
+ logger.warning(f'No valid embeddings found for dataset {dataset_id}')
71
+ self.faiss_search = None
72
+ self.current_dataset_id = None
73
+ else:
74
+ logger.warning(f'No entities found for dataset {dataset_id}')
75
+ self.faiss_search = None
76
+ self.current_dataset_id = None
77
+
78
+ def process_document(
79
+ self,
80
+ document: ParsedDocument,
81
+ dataset_id: int,
82
+ progress_callback: Optional[Callable] = None,
83
+ **destructurer_kwargs,
84
+ ) -> None:
85
+ """
86
+ Обработка документа: разбиение на чанки и сохранение в базу.
87
+
88
+ Args:
89
+ document: Документ для обработки
90
+ dataset_id: ID датасета
91
+ progress_callback: Функция для отслеживания прогресса
92
+ **destructurer_kwargs: Дополнительные параметры для Destructurer
93
+ """
94
+ logger.info(f"Processing document {document.name} for dataset {dataset_id}")
95
+
96
+ # Создаем деструктуризатор с параметрами по умолчанию
97
+ destructurer = Destructurer(
98
+ document,
99
+ strategy_name="fixed_size",
100
+ process_tables=True,
101
+ **{
102
+ "words_per_chunk": 50,
103
+ "overlap_words": 25,
104
+ "respect_sentence_boundaries": True,
105
+ **destructurer_kwargs,
106
+ }
107
+ )
108
+
109
+ # Получаем сущности
110
+ entities = destructurer.destructure()
111
+
112
+ # Фильтруем сущности для поиска
113
+ filtering_entities = [entity for entity in entities if entity.in_search_text is not None]
114
+ filtering_texts = [entity.in_search_text for entity in filtering_entities]
115
+
116
+ # Получаем эмбеддинги с поддержкой callback
117
+ embeddings = self.vectorizer.vectorize(filtering_texts, progress_callback)
118
+ embeddings_dict = {
119
+ str(entity.id): embedding # Преобразуем UUID в строку для ключа
120
+ for entity, embedding in zip(filtering_entities, embeddings)
121
+ }
122
+
123
+ # Сохраняем в базу
124
+ self.chunk_repository.add_entities(entities, dataset_id, embeddings_dict)
125
+
126
+ # Переинициализируем FAISS индекс, если это текущий датасет
127
+ if self.current_dataset_id == dataset_id:
128
+ self._ensure_faiss_initialized(dataset_id)
129
+
130
+ logger.info(f"Added {len(entities)} entities to dataset {dataset_id}")
131
+
132
+ def build_text(
133
+ self,
134
+ entities: list[LinkerEntity],
135
+ chunk_scores: Optional[list[float]] = None,
136
+ include_tables: bool = True,
137
+ max_documents: Optional[int] = None,
138
+ ) -> str:
139
+ """
140
+ Сборка текста из сущностей.
141
+
142
+ Args:
143
+ entities: Список сущностей
144
+ chunk_scores: Список весов чанков
145
+ include_tables: Флаг включения таблиц
146
+ max_documents: Максимальное количество документов
147
+
148
+ Returns:
149
+ Собранный текст
150
+ """
151
+ logger.info(f"Building text for {len(entities)} entities")
152
+ if chunk_scores is not None:
153
+ chunk_scores = {entity.id: score for entity, score in zip(entities, chunk_scores)}
154
+ builder = InjectionBuilder(self.chunk_repository)
155
+ return builder.build(
156
+ [entity.id for entity in entities], # Передаем UUID напрямую
157
+ chunk_scores=chunk_scores,
158
+ include_tables=include_tables,
159
+ max_documents=max_documents,
160
+ )
161
+
162
+ def search_similar(
163
+ self,
164
+ query: str,
165
+ dataset_id: int,
166
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
167
+ """
168
+ Поиск похожих сущностей.
169
+
170
+ Args:
171
+ query: Текст запроса
172
+ dataset_id: ID датасета
173
+
174
+ Returns:
175
+ tuple[np.ndarray, np.ndarray, np.ndarray]:
176
+ - Вектор запроса
177
+ - Оценки сходства
178
+ - Идентификаторы найденных сущностей
179
+ """
180
+ # Убеждаемся, что FAISS инициализирован для текущего датасета
181
+ self._ensure_faiss_initialized(dataset_id)
182
+
183
+ if self.faiss_search is None:
184
+ return np.array([]), np.array([]), np.array([])
185
+
186
+ # Выполняем поиск
187
+ return self.faiss_search.search_vectors(query)
188
+
189
+ def add_neighboring_chunks(
190
+ self,
191
+ entities: list[LinkerEntity],
192
+ max_distance: int = 1,
193
+ ) -> list[LinkerEntity]:
194
+ """
195
+ Добавление соседних чанков.
196
+
197
+ Args:
198
+ entities: Список сущностей
199
+ max_distance: Максимальное расстояние для поиска соседей
200
+
201
+ Returns:
202
+ Расширенный список сущностей
203
+ """
204
+ # Убедимся, что все ID представлены в UUID формате
205
+ for entity in entities:
206
+ if not isinstance(entity.id, UUID):
207
+ entity.id = UUID(str(entity.id))
208
+
209
+ builder = InjectionBuilder(self.chunk_repository)
210
+ return builder.add_neighboring_chunks(entities, max_distance)
lib/extractor/.cursor/rules/project-description.mdc ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: true
5
+ ---
6
+
7
+ # Project description
8
+
9
+ Данный проект представляет собой библиотеку, предоставляющую возможности для чанкинга и сборки
10
+ инъекций в промпт LLM для дальнейшего использования в RAG-системах. Основная логика описана в README.md и в architectures, если они не устарели. Ядро системы представляют классы LinkerEntity, Destructurer, EntityRepository, InjectionBuilder, ChunkingStrategy.
11
+
12
+ - LinkerEntity – основная сущность, от которой затем наследуются Chunk и DocumentAsEntity. Реализует триплетный подход, при котором один и тот же класс задаёт и сущности, и связи, и при этом сущности-ассоциации реализуются одним экземпляром, а не множеством.
13
+ - Destructurer – реализует логику разбиения документа на множество LinkerEntity, во многом делегируя работу различным ChunkingStrategy (но не всю).
14
+ - EntityRepository – интерфейс. Предполагается, что после извлечения всех сущностей посредством Destructurer пользователь библиотеки сохранит все свои сущности некоторым произвольным образом, например, в csv-файл или PostgreSQL. Библиотека не знает, как работать с пользовательскими хранилищами данных, поэтому пользователь должен сам написать реализацию EntityRepository для своего решения, и предоставить её в InjectionBuilder
15
+ - InjectionBuilder – сборщик промпт-инъекции. Принимает на вход отфильтрованный и (в отдельных случаях) оценённый некоторым скором набор сущностей, сортирует их, распределяет по документам и собирает всё в единый текст, пользуясь EntityRepository, чтобы достать связанные полезные сущности
16
+
17
+ Данная библиотека ориентируется на ParsedDocument из библиотеки ntr_fileparser, структура которого примерно соответствует следующему:
18
+
19
+ @dataclass
20
+ class ParsedDocument(ParsedStructure):
21
+ """
22
+ Документ, полученный в результате парсинга.
23
+ """
24
+ name: str = ""
25
+ type: str = ""
26
+ meta: ParsedMeta = field(default_factory=ParsedMeta)
27
+ paragraphs: list[ParsedTextBlock] = field(default_factory=list)
28
+ tables: list[ParsedTable] = field(default_factory=list)
29
+ images: list[ParsedImage] = field(default_factory=list)
30
+ formulas: list[ParsedFormula] = field(default_factory=list)
31
+
32
+ def to_string() -> str:
33
+ ...
34
+
35
+ def to_dict() -> dict:
36
+ ...
37
+
38
+
39
+ @dataclass
40
+ class ParsedTextBlock(DocumentElement):
41
+ """
42
+ Текстовый блок документа.
43
+ """
44
+
45
+ text: str = ""
46
+ style: TextStyle = field(default_factory=TextStyle)
47
+ anchors: list[str] = field(default_factory=list) # Список идентификаторов якорей (закладок)
48
+ links: list[str] = field(default_factory=list) # Список идентификаторов ссылок
49
+
50
+ # Технические метаданные о блоке
51
+ metadata: list[dict[str, Any]] = field(default_factory=list) # Для хранения технической информации
52
+
53
+ # Примечания и сноски к тексту
54
+ footnotes: list[dict[str, Any]] = field(default_factory=list) # Для хранения сносок
55
+
56
+ title_of_table: int | None = None
57
+
58
+ def to_string() -> str:
59
+ ...
60
+
61
+ def to_dict() -> dict:
62
+ ...
63
+
64
+
65
+ @dataclass
66
+ class ParsedTable(DocumentElement):
67
+ """
68
+ Таблица из документа.
69
+ """
70
+
71
+ title: str | None = None
72
+ note: str | None = None
73
+ classified_tags: list[TableTag] = field(default_factory=list)
74
+ index: list[str] = field(default_factory=list)
75
+ headers: list[ParsedRow] = field(default_factory=list)
76
+ subtables: list[ParsedSubtable] = field(default_factory=list)
77
+ table_style: dict[str, Any] = field(default_factory=dict)
78
+ title_index_in_paragraphs: int | None = None
79
+
80
+ def to_string() -> str:
81
+ ...
82
+
83
+ def to_dict() -> dict:
84
+ ...
85
+
86
+ (Дальнейшую информацию о вложенных классах ты можешь уточнить у по��ьзователя, если это будет нужно)
lib/extractor/.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use_it/*
2
+ test_output/
3
+ test_input/
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.pyd
8
+ *.pyw
9
+ *.pyz
10
+
11
+ *.egg-info/
lib/extractor/README.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Библиотека извлечения и сборки документов
2
+
3
+ Библиотека для извлечения структурированной информации из документов и их последующей сборки.
4
+
5
+ ## Основные компоненты
6
+
7
+ - **Destructurer**: Разбивает документ на чанки и связи между ними, а также извлекает дополнительные сущности
8
+ - **Builder**: Собирает документ из чанков и связей
9
+ - **Entity**: Базовый класс для всех сущностей (Document, Chunk, Acronym и т.д.)
10
+ - **Link**: Класс для представления связей между сущностями
11
+ - **ChunkingStrategy**: Интерфейс для различных стратегий чанкинга
12
+ - **TablesProcessor**: Процессор для извлечения таблиц из документа
13
+
14
+ ## Установка
15
+
16
+ ```bash
17
+ pip install -e .
18
+ ```
19
+
20
+ ## Использование
21
+
22
+ ```python
23
+ from ntr_text_fragmentation.core import Destructurer, Builder
24
+ from ntr_fileparser import ParsedDocument
25
+
26
+ # Пример использования Destructurer с обработкой таблиц
27
+ document = ParsedDocument(...)
28
+ destructurer = Destructurer(
29
+ document=document,
30
+ strategy_name="fixed_size",
31
+ process_tables=True
32
+ )
33
+ entities = destructurer.destructure()
34
+
35
+ # Пример использования Builder
36
+ builder = Builder(document)
37
+ builder.configure({"chunking_strategy": "fixed_size"})
38
+ reconstructed_document = builder.build()
39
+ ```
40
+
41
+ ## Модули
42
+
43
+ ### Core
44
+ Основные классы для работы с документами:
45
+ - **Destructurer**: Разбивает документ на чанки и другие сущности
46
+ - **Builder**: Собирает документ из чанков и связей
47
+
48
+ ### Chunking
49
+ Различные стратегии разбиения документа на чанки:
50
+ - **FixedSizeChunkingStrategy**: Разбиение на чанки фиксированного размера
51
+
52
+ ### Additors
53
+ Дополнительные обработчики для извлечения сущностей:
54
+ - **TablesProcessor**: Извлекает таблицы из документа и создает для них сущности
55
+
56
+ ### Models
57
+ Модели данных для представления сущностей и связей:
58
+ - **LinkerEntity**: Базовый класс для всех сущностей и связей
59
+ - **DocumentAsEntity**: Представление документа как сущности
60
+ - **TableEntity**: Представление таблицы как сущности
lib/extractor/docs/architecture.puml ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @startuml "NTR Text Fragmentation Architecture"
2
+
3
+ ' Использование CSS-стилей вместо skinparams
4
+ <style>
5
+ .concrete {
6
+ BackgroundColor #FFFFFF
7
+ BorderColor #795548
8
+ }
9
+
10
+ .models {
11
+ BackgroundColor #E8F5E9
12
+ BorderColor #4CAF50
13
+ }
14
+
15
+ .strategies {
16
+ BackgroundColor #E1F5FE
17
+ BorderColor #03A9F4
18
+ }
19
+
20
+ .core {
21
+ BackgroundColor #FFEBEE
22
+ BorderColor #F44336
23
+ }
24
+
25
+ note {
26
+ BackgroundColor #FFF9C4
27
+ BorderColor #FFD54F
28
+ FontSize 10
29
+ }
30
+ </style>
31
+
32
+ ' Легенда
33
+ legend
34
+ <b>Легенда</b>
35
+
36
+ | Цвет | Описание |
37
+ | <back:#E8F5E9>Зеленый</back> | Модели данных |
38
+ | <back:#E1F5FE>Голубой</back> | Стратегии чанкинга |
39
+ | <back:#FFEBEE>Красный</back> | Основные компоненты |
40
+ endlegend
41
+
42
+ ' Разделение на пакеты
43
+
44
+ package "models" {
45
+ class LinkerEntity <<models>> {
46
+ + id: UUID
47
+ + name: str
48
+ + text: str
49
+ + in_search_text: str | None
50
+ + metadata: dict
51
+ + source_id: UUID | None
52
+ + target_id: UUID | None
53
+ + number_in_relation: int | None
54
+ + type: str
55
+ + serialize(): LinkerEntity
56
+ + {abstract} deserialize(data: LinkerEntity): Self
57
+ }
58
+
59
+ class Chunk <<models>> extends LinkerEntity {
60
+ + chunk_index: int | None
61
+ }
62
+
63
+ class DocumentAsEntity <<models>> extends LinkerEntity {
64
+ }
65
+
66
+ note right of LinkerEntity
67
+ Базовая сущность для всех элементов системы.
68
+ in_search_text определяет текст, используемый
69
+ при поиске, если None - данная сущность не должна попасть
70
+ в поиск и используется только для вспомогательных целей.
71
+ end note
72
+ }
73
+
74
+ package "chunking_strategies" as chunking_strategies {
75
+ abstract class ChunkingStrategy <<abstract>> {
76
+ + {abstract} chunk(document: ParsedDocument, doc_entity: DocumentAsEntity): list[LinkerEntity]
77
+ + dechunk(entities: list[LinkerEntity], links: list[LinkerEntity]): str
78
+ }
79
+
80
+ package "specific_strategies" {
81
+ class FixedSizeChunkingStrategy <<strategies>> extends chunking_strategies.ChunkingStrategy {
82
+ + chunk(document: ParsedDocument, doc_entity: DocumentAsEntity): list[LinkerEntity]
83
+ + dechunk(entities: list[LinkerEntity], links: list[LinkerEntity]): str
84
+ }
85
+
86
+ class SentenceChunkingStrategy <<strategies>> extends chunking_strategies.ChunkingStrategy {
87
+ + chunk(document: ParsedDocument, doc_entity: DocumentAsEntity): list[LinkerEntity]
88
+ + dechunk(entities: list[LinkerEntity], links: list[LinkerEntity]): str
89
+ }
90
+
91
+ class NumberedItemsChunkingStrategy <<strategies>> extends chunking_strategies.ChunkingStrategy {
92
+ + chunk(document: ParsedDocument, doc_entity: DocumentAsEntity): list[LinkerEntity]
93
+ + dechunk(entities: list[LinkerEntity], links: list[LinkerEntity]): str
94
+ }
95
+ }
96
+
97
+ note right of ChunkingStrategy
98
+ Базовая реализация dechunk сортирует чанки по chunk_index.
99
+ Стратегии могут переопределить, если им нужна
100
+ специфическая логика сборки
101
+ end note
102
+ }
103
+
104
+ package "core" {
105
+ class Destructurer <<core>> {
106
+ + __init__(document: ParsedDocument, strategy_name: str)
107
+ + configure(strategy_name: str, **kwargs)
108
+ + destructure(): list[LinkerEntity]
109
+ }
110
+
111
+ class InjectionBuilder <<core>> {
112
+ + __init__(entities: list[LinkerEntity], config: dict)
113
+ + register_strategy(doc_type: str, strategy: ChunkingStrategy)
114
+ + build(filtered_entities: list[LinkerEntity]): str
115
+ - _group_chunks_by_document(chunks, links): dict
116
+ }
117
+
118
+ note right of Destructurer
119
+ Основной класс библиотеки, используется для разбиения
120
+ документа на чанки и вспомогательные сущности. В
121
+ полученной конфигурации содержатся in_search сущности
122
+ и множество вспомогательных сущностей. Предполагается,
123
+ что первые будут отфильтрованы векторным или иным поиском,
124
+ а вторые можно будет использовать для обогащения и сборки
125
+ итоговой инъекции в промпт.
126
+ end note
127
+
128
+ note right of InjectionBuilder
129
+ Класс-единая точка входа для сборки итоговой инъекции
130
+ в промпт. Принимает в себя все сущности и конфигурацию
131
+ в конструкторе, а в методе build принимает отфильтрованные
132
+ сущности. Может частично делегировать сборку стратегиям для
133
+ специфических ти��ов чанкинга.
134
+ end note
135
+
136
+ }
137
+
138
+ ' Композиционные отношения
139
+ core.Destructurer --> chunking_strategies.ChunkingStrategy
140
+ core.InjectionBuilder --> chunking_strategies.ChunkingStrategy
141
+
142
+ ' Отношения между компонентами
143
+ chunking_strategies.ChunkingStrategy ..> models
144
+
145
+ ' Дополнительные отношения
146
+ core.InjectionBuilder ..> models.LinkerEntity
147
+ core.Destructurer ..> models.LinkerEntity
148
+
149
+ @enduml
lib/extractor/ntr_text_fragmentation/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль извлечения и сборки документов.
3
+ """
4
+
5
+ from .core.destructurer import Destructurer
6
+ from .core.entity_repository import EntityRepository, InMemoryEntityRepository
7
+ from .core.injection_builder import InjectionBuilder
8
+ from .models import Chunk, DocumentAsEntity, LinkerEntity
9
+
10
+ __all__ = [
11
+ "Destructurer",
12
+ "InjectionBuilder",
13
+ "EntityRepository",
14
+ "InMemoryEntityRepository",
15
+ "LinkerEntity",
16
+ "Chunk",
17
+ "DocumentAsEntity",
18
+ "integrations",
19
+ ]
lib/extractor/ntr_text_fragmentation/additors/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль для дополнительных обработчиков документа.
3
+
4
+ Содержит обработчики, которые извлекают дополнительные сущности из документа,
5
+ например, таблицы, изображения и т.д.
6
+ """
7
+
8
+ from .tables_processor import TablesProcessor
9
+
10
+ __all__ = ["TablesProcessor"]
lib/extractor/ntr_text_fragmentation/additors/tables/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .table_entity import TableEntity
2
+
3
+ __all__ = [
4
+ 'TableEntity',
5
+ ]
lib/extractor/ntr_text_fragmentation/additors/tables/table_entity.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+ from uuid import UUID
4
+
5
+ from ...models import LinkerEntity
6
+ from ...models.linker_entity import register_entity
7
+
8
+
9
+ @register_entity
10
+ @dataclass
11
+ class TableEntity(LinkerEntity):
12
+ """
13
+ Сущность таблицы из документа.
14
+
15
+ Расширяет основную сущность LinkerEntity, добавляя информацию о таблице.
16
+ """
17
+
18
+ table_index: Optional[int] = None
19
+
20
+ @classmethod
21
+ def deserialize(cls, entity: LinkerEntity) -> "TableEntity":
22
+ """
23
+ Десериализует сущность из базового LinkerEntity.
24
+
25
+ Args:
26
+ entity: Базовая сущность LinkerEntity
27
+
28
+ Returns:
29
+ Десериализованная сущность TableEntity
30
+ """
31
+ if entity.type != cls.__name__:
32
+ raise ValueError(f"Неверный тип сущности: {entity.type}, ожидался {cls.__name__}")
33
+
34
+ # Извлекаем дополнительные поля из метаданных
35
+ metadata = entity.metadata or {}
36
+ table_index = metadata.get("table_index")
37
+
38
+ return cls(
39
+ id=entity.id if isinstance(entity.id, UUID) else UUID(entity.id),
40
+ name=entity.name,
41
+ text=entity.text,
42
+ in_search_text=entity.in_search_text,
43
+ metadata=entity.metadata,
44
+ source_id=entity.source_id,
45
+ target_id=entity.target_id,
46
+ number_in_relation=entity.number_in_relation,
47
+ type=entity.type,
48
+ table_index=table_index,
49
+ )
50
+
51
+ def serialize(self) -> LinkerEntity:
52
+ """
53
+ Сериализует сущность в базовый LinkerEntity.
54
+
55
+ Returns:
56
+ Сериализованная сущность LinkerEntity
57
+ """
58
+ metadata = self.metadata or {}
59
+
60
+ # Добавляем дополнительные поля в метаданные
61
+ if self.table_index is not None:
62
+ metadata["table_index"] = self.table_index
63
+
64
+ return LinkerEntity(
65
+ id=self.id,
66
+ name=self.name,
67
+ text=self.text,
68
+ in_search_text=self.in_search_text,
69
+ metadata=metadata,
70
+ source_id=self.source_id,
71
+ target_id=self.target_id,
72
+ number_in_relation=self.number_in_relation,
73
+ type=self.__class__.__name__,
74
+ )
lib/extractor/ntr_text_fragmentation/additors/tables_processor.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Процессор таблиц из документа.
3
+ """
4
+
5
+ from uuid import uuid4
6
+
7
+ from ntr_fileparser import ParsedDocument
8
+
9
+ from ..models import LinkerEntity
10
+ from .tables import TableEntity
11
+
12
+
13
+ class TablesProcessor:
14
+ """
15
+ Процессор для извлечения таблиц из документа и создания связанных сущностей.
16
+ """
17
+
18
+ def __init__(self):
19
+ """Инициализация процессора таблиц."""
20
+ pass
21
+
22
+ def process(
23
+ self,
24
+ document: ParsedDocument,
25
+ doc_entity: LinkerEntity,
26
+ ) -> list[LinkerEntity]:
27
+ """
28
+ Извлекает таблицы из документа и создает для них сущности.
29
+
30
+ Args:
31
+ document: Документ для обработки
32
+ doc_entity: Сущность документа для связи с таблицами
33
+
34
+ Returns:
35
+ Список сущностей TableEntity и связей
36
+ """
37
+ if not document.tables:
38
+ return []
39
+
40
+ table_entities = []
41
+ links = []
42
+
43
+ rows = '\n\n'.join([table.to_string() for table in document.tables]).split(
44
+ '\n\n'
45
+ )
46
+
47
+ # Обрабатываем каждую таблицу
48
+ for idx, row in enumerate(rows):
49
+ # Создаем сущность таблицы
50
+ table_entity = self._create_table_entity(
51
+ table_text=row,
52
+ table_index=idx,
53
+ doc_name=doc_entity.name,
54
+ )
55
+
56
+ # Создаем связь между документом и таблицей
57
+ link = self._create_link(doc_entity, table_entity, idx)
58
+
59
+ table_entities.append(table_entity)
60
+ links.append(link)
61
+
62
+ # Возвращаем список таблиц и связей
63
+ return table_entities + links
64
+
65
+ def _create_table_entity(
66
+ self,
67
+ table_text: str,
68
+ table_index: int,
69
+ doc_name: str,
70
+ ) -> TableEntity:
71
+ """
72
+ Создает сущность таблицы.
73
+
74
+ Args:
75
+ table_text: Текст таблицы
76
+ table_index: Индекс таблицы в документе
77
+ doc_name: Имя документа
78
+
79
+ Returns:
80
+ Сущность TableEntity
81
+ """
82
+ entity_name = f"{doc_name}_table_{table_index}"
83
+
84
+ return TableEntity(
85
+ id=uuid4(),
86
+ name=entity_name,
87
+ text=table_text,
88
+ in_search_text=table_text,
89
+ metadata={},
90
+ type=TableEntity.__name__,
91
+ table_index=table_index,
92
+ )
93
+
94
+ def _create_link(
95
+ self, doc_entity: LinkerEntity, table_entity: TableEntity, index: int
96
+ ) -> LinkerEntity:
97
+ """
98
+ Создает связь между документом и таблицей.
99
+
100
+ Args:
101
+ doc_entity: Сущность документа
102
+ table_entity: Сущность таблицы
103
+ index: Индекс таблицы в документе
104
+
105
+ Returns:
106
+ Объект связи LinkerEntity
107
+ """
108
+ return LinkerEntity(
109
+ id=uuid4(),
110
+ name="document_to_table",
111
+ text="",
112
+ metadata={},
113
+ source_id=doc_entity.id,
114
+ target_id=table_entity.id,
115
+ number_in_relation=index,
116
+ type="Link",
117
+ )
lib/extractor/ntr_text_fragmentation/chunking/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль для определения стратегий чанкинга.
3
+ """
4
+
5
+ from .chunking_strategy import ChunkingStrategy
6
+ from .specific_strategies import FixedSizeChunkingStrategy
7
+
8
+ __all__ = [
9
+ "ChunkingStrategy",
10
+ "FixedSizeChunkingStrategy",
11
+ ]
lib/extractor/ntr_text_fragmentation/chunking/chunking_strategy.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Базовый класс для всех стратегий чанкинга.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from ntr_fileparser import ParsedDocument
8
+
9
+ from ..models import Chunk, DocumentAsEntity, LinkerEntity
10
+
11
+
12
+ class ChunkingStrategy(ABC):
13
+ """
14
+ Базовый абстрактный класс для всех стратегий чанкинга.
15
+ """
16
+
17
+ @abstractmethod
18
+ def chunk(self, document: ParsedDocument, doc_entity: DocumentAsEntity | None = None) -> list[LinkerEntity]:
19
+ """
20
+ Разбивает документ на чанки в соответствии со стратегией.
21
+
22
+ Args:
23
+ document: ParsedDocument для извлечения текста
24
+ doc_entity: Опциональная сущность документа для привязки чанков.
25
+ Если не указана, будет создана новая.
26
+
27
+ Returns:
28
+ list[LinkerEntity]: Список сущностей (документ, чанки, связи)
29
+ """
30
+ raise NotImplementedError("Стратегия чанкинга должна реализовать метод chunk")
31
+
32
+ def dechunk(self, chunks: list[LinkerEntity], repository: 'EntityRepository' = None) -> str:
33
+ """
34
+ Собирает документ из чанков и связей.
35
+
36
+ Базовая реализация сортирует чанки по chunk_index и объединяет их тексты,
37
+ сохраняя структуру параграфов и избегая дублирования текста.
38
+
39
+ Args:
40
+ chunks: Список отфильтрованных чанков в случайном порядке
41
+ repository: Репозиторий сущностей для получения дополнительной информации (может быть None)
42
+
43
+ Returns:
44
+ Восстановленный текст документа
45
+ """
46
+ import re
47
+
48
+ # Проверяем, есть ли чанки для сборки
49
+ if not chunks:
50
+ return ""
51
+
52
+ # Отбираем только чанки
53
+ valid_chunks = [c for c in chunks if isinstance(c, Chunk)]
54
+
55
+ # Сортируем чанки по chunk_index
56
+ sorted_chunks = sorted(valid_chunks, key=lambda c: c.chunk_index or 0)
57
+
58
+ # Собираем текст документа с учетом структуры параграфов
59
+ result_text = ""
60
+
61
+ for chunk in sorted_chunks:
62
+ # Получаем текст чанка (предпочитаем text, а не in_search_text для избежания дублирования)
63
+ chunk_text = chunk.text if hasattr(chunk, 'text') and chunk.text else ""
64
+
65
+ # Добавляем текст чанка с сохранением структуры параграфов
66
+ if result_text and result_text[-1] != "\n" and chunk_text and chunk_text[0] != "\n":
67
+ result_text += " "
68
+ result_text += chunk_text
69
+
70
+ # Пост-обработка результата
71
+ # Заменяем множественные переносы строк на одиночные
72
+ result_text = re.sub(r'\n+', '\n', result_text)
73
+
74
+ # Заменяем множественные пробелы на одиночные
75
+ result_text = re.sub(r' +', ' ', result_text)
76
+
77
+ # Убираем пробелы перед переносами строк
78
+ result_text = re.sub(r' +\n', '\n', result_text)
79
+
80
+ # Убираем пробелы после переносов строк
81
+ result_text = re.sub(r'\n +', '\n', result_text)
82
+
83
+ # Убираем лишние переносы строк в начале и конце текста
84
+ result_text = result_text.strip()
85
+
86
+ return result_text
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль содержащий конкретные стратегии для чанкинга текста.
3
+ """
4
+
5
+ from .fixed_size import FixedSizeChunk
6
+ from .fixed_size_chunking import FixedSizeChunkingStrategy
7
+
8
+ __all__ = [
9
+ "FixedSizeChunk",
10
+ "FixedSizeChunkingStrategy",
11
+ ]
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль реализующий стратегию чанкинга с фиксированным размером.
3
+ """
4
+
5
+ from .fixed_size_chunk import FixedSizeChunk
6
+
7
+ __all__ = [
8
+ "FixedSizeChunk",
9
+ ]
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size/fixed_size_chunk.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Класс для представления чанка фиксированного размера.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from ....models.chunk import Chunk
9
+ from ....models.linker_entity import LinkerEntity, register_entity
10
+
11
+
12
+ @register_entity
13
+ @dataclass
14
+ class FixedSizeChunk(Chunk):
15
+ """
16
+ Представляет чанк фиксированного размера.
17
+
18
+ Расширяет базовый класс Chunk дополнительными полями, связанными с токенами
19
+ и перекрытиями, а также добавляет методы для сборки документа с учетом
20
+ границ предложений.
21
+ """
22
+
23
+ token_count: int = 0
24
+
25
+ # Информация о границах предложений и нахлестах
26
+ left_sentence_part: str = "" # Часть предложения слева от text
27
+ right_sentence_part: str = "" # Часть предложения справа от text
28
+ overlap_left: str = "" # Нахлест слева (без учета границ предложений)
29
+ overlap_right: str = "" # Нахлест справа (без учета границ предложений)
30
+
31
+ # Метаданные для дополнительной информации
32
+ metadata: dict[str, Any] = field(default_factory=dict)
33
+
34
+ def __str__(self) -> str:
35
+ """
36
+ Строковое представление чанка.
37
+
38
+ Returns:
39
+ Строка с информацией о чанке.
40
+ """
41
+ return (
42
+ f"FixedSizeChunk(id={self.id}, chunk_index={self.chunk_index}, "
43
+ f"tokens={self.token_count}, "
44
+ f"text='{self.text[:30]}{'...' if len(self.text) > 30 else ''}'"
45
+ f")"
46
+ )
47
+
48
+ def get_adjacent_chunks_indices(self, max_distance: int = 1) -> list[int]:
49
+ """
50
+ Возвращает индексы соседних чанков в пределах указанного расстояния.
51
+
52
+ Args:
53
+ max_distance: Максимальное расстояние от текущего чанка
54
+
55
+ Returns:
56
+ Список индексов соседних чанков
57
+ """
58
+ indices = []
59
+ for i in range(1, max_distance + 1):
60
+ # Добавляем предыдущие чанки
61
+ if self.chunk_index - i >= 0:
62
+ indices.append(self.chunk_index - i)
63
+ # Добавляем следующие чанки
64
+ indices.append(self.chunk_index + i)
65
+
66
+ return sorted(indices)
67
+
68
+ @classmethod
69
+ def deserialize(cls, entity: LinkerEntity) -> 'FixedSizeChunk':
70
+ """
71
+ Десериализует FixedSizeChunk из объекта LinkerEntity.
72
+
73
+ Args:
74
+ entity: Объект LinkerEntity для преобразования в FixedSizeChunk
75
+
76
+ Returns:
77
+ Десериализованный объект FixedSizeChunk
78
+ """
79
+ metadata = entity.metadata or {}
80
+
81
+ # Извлекаем параметры из метаданных
82
+ # Сначала проверяем в метаданных под ключом _chunk_index
83
+ chunk_index = metadata.get('_chunk_index')
84
+ if chunk_index is None:
85
+ # Затем пробуем получить как атрибут объекта
86
+ chunk_index = getattr(entity, 'chunk_index', None)
87
+ if chunk_index is None:
88
+ # Если и там нет, пробуем обычный поиск по метаданным
89
+ chunk_index = metadata.get('chunk_index')
90
+
91
+ # Преобразуем к int, если значение найдено
92
+ if chunk_index is not None:
93
+ try:
94
+ chunk_index = int(chunk_index)
95
+ except (ValueError, TypeError):
96
+ chunk_index = None
97
+
98
+ start_token = metadata.get('start_token', 0)
99
+ end_token = metadata.get('end_token', 0)
100
+ token_count = metadata.get(
101
+ '_token_count', metadata.get('token_count', end_token - start_token + 1)
102
+ )
103
+
104
+ # Извлекаем параметры для границ предложений и нахлестов
105
+ # Сначала ищем в метаданных с префиксом _
106
+ left_sentence_part = metadata.get('_left_sentence_part')
107
+ if left_sentence_part is None:
108
+ # Затем пробуем получить как атрибут объекта
109
+ left_sentence_part = getattr(entity, 'left_sentence_part', '')
110
+
111
+ right_sentence_part = metadata.get('_right_sentence_part')
112
+ if right_sentence_part is None:
113
+ right_sentence_part = getattr(entity, 'right_sentence_part', '')
114
+
115
+ overlap_left = metadata.get('_overlap_left')
116
+ if overlap_left is None:
117
+ overlap_left = getattr(entity, 'overlap_left', '')
118
+
119
+ overlap_right = metadata.get('_overlap_right')
120
+ if overlap_right is None:
121
+ overlap_right = getattr(entity, 'overlap_right', '')
122
+
123
+ # Создаем чистые метаданные без служебных полей
124
+ clean_metadata = {k: v for k, v in metadata.items() if not k.startswith('_')}
125
+
126
+ # Создаем и возвращаем новый экземпляр FixedSizeChunk
127
+ return cls(
128
+ id=entity.id,
129
+ name=entity.name,
130
+ text=entity.text,
131
+ in_search_text=entity.in_search_text,
132
+ metadata=clean_metadata,
133
+ source_id=entity.source_id,
134
+ target_id=entity.target_id,
135
+ number_in_relation=entity.number_in_relation,
136
+ chunk_index=chunk_index,
137
+ token_count=token_count,
138
+ left_sentence_part=left_sentence_part,
139
+ right_sentence_part=right_sentence_part,
140
+ overlap_left=overlap_left,
141
+ overlap_right=overlap_right,
142
+ type="FixedSizeChunk",
143
+ )
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size_chunking.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Стратегия чанкинга фиксированного размера.
3
+ """
4
+
5
+ import re
6
+ from typing import NamedTuple, TypeVar
7
+ from uuid import uuid4
8
+
9
+ from ntr_fileparser import ParsedDocument, ParsedTextBlock
10
+
11
+ from ...chunking.chunking_strategy import ChunkingStrategy
12
+ from ...models import DocumentAsEntity, LinkerEntity
13
+ from .fixed_size.fixed_size_chunk import FixedSizeChunk
14
+
15
+ T = TypeVar('T')
16
+
17
+
18
+ class _FixedSizeChunkingStrategyParams(NamedTuple):
19
+ words_per_chunk: int = 50
20
+ overlap_words: int = 25
21
+ respect_sentence_boundaries: bool = True
22
+
23
+
24
+ class FixedSizeChunkingStrategy(ChunkingStrategy):
25
+ """
26
+ Стратегия чанкинга, разбивающая текст на чанки фиксированного размера.
27
+
28
+ Преимущества:
29
+ - Простое и предсказуемое разбиение
30
+ - Равные по размеру чанки
31
+
32
+ Недостатки:
33
+ - Может разрезать предложения и абзацы в середине (компенсируется сборкой - как для модели поиска, так и для LLM)
34
+ - Не учитывает смысловую структуру текста
35
+
36
+ Особенности реализации:
37
+ - В поле `text` чанков хранится текст без нахлеста (для удобства сборки)
38
+ - В поле `in_search_text` хранится текст с нахлестом (для улучшения векторизации)
39
+ """
40
+
41
+ name = "fixed_size"
42
+ description = (
43
+ "Стратегия чанкинга, разбивающая текст на чанки фиксированного размера."
44
+ )
45
+
46
+ def __init__(
47
+ self,
48
+ words_per_chunk: int = 50,
49
+ overlap_words: int = 25,
50
+ respect_sentence_boundaries: bool = True,
51
+ ):
52
+ """
53
+ Инициализация стратегии чанкинга с фиксированным размером.
54
+
55
+ Args:
56
+ words_per_chunk: Количество слов в чанке
57
+ overlap_words: Количество слов перекрытия между чанками
58
+ respect_sentence_boundaries: Флаг учета границ предложений
59
+ """
60
+
61
+ self.params = _FixedSizeChunkingStrategyParams(
62
+ words_per_chunk=words_per_chunk,
63
+ overlap_words=overlap_words,
64
+ respect_sentence_boundaries=respect_sentence_boundaries,
65
+ )
66
+
67
+ def chunk(
68
+ self,
69
+ document: ParsedDocument | str,
70
+ doc_entity: DocumentAsEntity | None = None,
71
+ ) -> list[LinkerEntity]:
72
+ """
73
+ Разбивает документ на чанки фиксированного размера.
74
+
75
+ Args:
76
+ document: Документ для разбиения (ParsedDocument или текст)
77
+ doc_entity: Сущность документа (опционально)
78
+
79
+ Returns:
80
+ Список LinkerEntity - чанки, связи и прочие сущности
81
+ """
82
+ doc = self._prepare_document(document)
83
+ words = self._extract_words(doc)
84
+
85
+ # Если документ пустой, возвращаем пустой список
86
+ if not words:
87
+ return []
88
+
89
+ doc_entity = self._ensure_document_entity(doc, doc_entity)
90
+ doc_name = doc_entity.name
91
+
92
+ chunks = []
93
+ links = []
94
+
95
+ step = self._calculate_step()
96
+ total_words = len(words)
97
+
98
+ # Начинаем с первого слова и идем шагами (не полным размером чанка)
99
+ for i in range(0, total_words, step):
100
+ # Создаем обычный чанк
101
+ chunk_text = self._prepare_chunk_text(words, i, step)
102
+ in_search_text = self._prepare_chunk_text(
103
+ words, i, self.params.words_per_chunk
104
+ )
105
+
106
+ chunk = self._create_chunk(
107
+ chunk_text,
108
+ in_search_text,
109
+ i,
110
+ i + self.params.words_per_chunk,
111
+ len(chunks),
112
+ words,
113
+ total_words,
114
+ doc_name,
115
+ )
116
+
117
+ chunks.append(chunk)
118
+ links.append(self._create_link(doc_entity, chunk))
119
+
120
+ # Возвращаем все сущности
121
+ return [doc_entity] + chunks + links
122
+
123
+ def _find_nearest_sentence_boundary(
124
+ self, text: str, position: int
125
+ ) -> tuple[int, str, str]:
126
+ """
127
+ Находит ближайшую границу предложения к указанной позиции.
128
+
129
+ Args:
130
+ text: Полный текст для поиска границ
131
+ position: Позиция, для которой ищем ближайшую границу
132
+
133
+ Returns:
134
+ tuple из (позиция границы, левая часть текста, правая часть текста)
135
+ """
136
+ # Регулярное выражение для поиска конца предложения
137
+ sentence_end_pattern = r'[.!?](?:\s|$)'
138
+
139
+ # Ищем все совпадения в тексте
140
+ matches = list(re.finditer(sentence_end_pattern, text))
141
+
142
+ if not matches:
143
+ # Если совпадений нет, возвращаем исходную позицию
144
+ return position, text[:position], text[position:]
145
+
146
+ # Находим ближайшую границу предложения
147
+ nearest_pos = position
148
+ min_distance = float('inf')
149
+
150
+ for match in matches:
151
+ end_pos = match.end()
152
+ distance = abs(end_pos - position)
153
+
154
+ if distance < min_distance:
155
+ min_distance = distance
156
+ nearest_pos = end_pos
157
+
158
+ # Возвращаем позицию и соответствующие части текста
159
+ return nearest_pos, text[:nearest_pos], text[nearest_pos:]
160
+
161
+ def _find_sentence_boundary(self, text: str, is_left_boundary: bool) -> str:
162
+ """
163
+ Находит часть текста на границе предложения.
164
+
165
+ Args:
166
+ text: Текст для обработки
167
+ is_left_boundary: True для левой границы, False для правой
168
+
169
+ Returns:
170
+ Часть предложения на границе
171
+ """
172
+ # Регулярное выражение для поиска конца предложения
173
+ sentence_end_pattern = r'[.!?](?:\s|$)'
174
+ matches = list(re.finditer(sentence_end_pattern, text))
175
+
176
+ if not matches:
177
+ return text
178
+
179
+ if is_left_boundary:
180
+ # Для левой границы берем часть после последней границы предложения
181
+ last_match = matches[-1]
182
+ return text[last_match.end() :].strip()
183
+ else:
184
+ # Для правой границы берем часть до первой границы предложения
185
+ first_match = matches[0]
186
+ return text[: first_match.end()].strip()
187
+
188
+ def dechunk(
189
+ self,
190
+ filtered_chunks: list[LinkerEntity],
191
+ repository: 'EntityRepository' = None, # type: ignore
192
+ ) -> str:
193
+ """
194
+ Собирает документ из чанков и связей.
195
+
196
+ Args:
197
+ filtered_chunks: Список отфильтрованных чанков
198
+ repository: Репозиторий сущностей для получения дополнительной информации (может быть None)
199
+
200
+ Returns:
201
+ Восстановленный текст документа
202
+ """
203
+ if not filtered_chunks:
204
+ return ""
205
+
206
+ # Проверяем тип и десериализуем FixedSizeChunk
207
+ chunks = []
208
+ for chunk in filtered_chunks:
209
+ if chunk.type == "FixedSizeChunk":
210
+ chunks.append(FixedSizeChunk.deserialize(chunk))
211
+ else:
212
+ chunks.append(chunk)
213
+
214
+ # Сортируем чанки по индексу
215
+ sorted_chunks = sorted(chunks, key=lambda c: c.chunk_index or 0)
216
+
217
+ # Инициализируем результирующий текст
218
+ result_text = ""
219
+
220
+ # Группируем последовательные чанки
221
+ current_group = []
222
+ groups = []
223
+
224
+ for i, chunk in enumerate(sorted_chunks):
225
+ current_index = chunk.chunk_index or 0
226
+
227
+ # Если первый чанк или продолжение последовательности
228
+ if i == 0 or current_index == (sorted_chunks[i - 1].chunk_index or 0) + 1:
229
+ current_group.append(chunk)
230
+ else:
231
+ # Закрываем текущую группу и начинаем новую
232
+ if current_group:
233
+ groups.append(current_group)
234
+ current_group = [chunk]
235
+
236
+ # Добавляем последнюю группу
237
+ if current_group:
238
+ groups.append(current_group)
239
+
240
+ # Обрабатываем каждую группу
241
+ for group_index, group in enumerate(groups):
242
+ # Добавляем многоточие между непоследовательными группами
243
+ if group_index > 0:
244
+ result_text += "\n\n...\n\n"
245
+
246
+ # Обрабатываем группу соседних чанков
247
+ group_text = ""
248
+
249
+ # Добавляем левую недостающую часть к первому чанку группы
250
+ first_chunk = group[0]
251
+
252
+ # До��авляем левую часть предложения к первому чанку группы
253
+ if (
254
+ hasattr(first_chunk, 'left_sentence_part')
255
+ and first_chunk.left_sentence_part
256
+ ):
257
+ group_text += first_chunk.left_sentence_part
258
+
259
+ # Добавляем текст всех чанков группы
260
+ for i, chunk in enumerate(group):
261
+ current_text = chunk.text.strip() if hasattr(chunk, 'text') else ""
262
+ if not current_text:
263
+ continue
264
+
265
+ # Проверяем, нужно ли добавить пробел между предыдущим текстом и текущим чанком
266
+ if group_text:
267
+ # Если текущий чанк начинается с новой строки, не добавляем пробел
268
+ if current_text.startswith("\n"):
269
+ pass
270
+ # Если предыдущий текст заканчивается переносом строки, также не добавляем пробел
271
+ elif group_text.endswith("\n"):
272
+ pass
273
+ # Если предыдущий текст заканчивается знаком препинания без пробела, добавляем пробел
274
+ elif group_text.rstrip()[-1] not in [
275
+ "\n",
276
+ " ",
277
+ ".",
278
+ ",",
279
+ "!",
280
+ "?",
281
+ ":",
282
+ ";",
283
+ "-",
284
+ "–",
285
+ "—",
286
+ ]:
287
+ group_text += " "
288
+
289
+ # Добавляем текст чанка
290
+ group_text += current_text
291
+
292
+ # Добавляем правую недостающую часть к последнему чанку группы
293
+ last_chunk = group[-1]
294
+
295
+ # Добавляем правую часть предложения к последнему чанку группы
296
+ if (
297
+ hasattr(last_chunk, 'right_sentence_part')
298
+ and last_chunk.right_sentence_part
299
+ ):
300
+ right_part = last_chunk.right_sentence_part.strip()
301
+ if right_part:
302
+ # Проверяем нужен ли пробел перед правой частью
303
+ if (
304
+ group_text
305
+ and group_text[-1] not in ["\n", " "]
306
+ and right_part[0]
307
+ not in ["\n", " ", ".", ",", "!", "?", ":", ";", "-", "–", "—"]
308
+ ):
309
+ group_text += " "
310
+ group_text += right_part
311
+
312
+ # Добавляем текст группы к результату
313
+ if (
314
+ result_text
315
+ and result_text[-1] not in ["\n", " "]
316
+ and group_text
317
+ and group_text[0] not in ["\n", " "]
318
+ ):
319
+ result_text += " "
320
+ result_text += group_text
321
+
322
+ # Постобработка текста: удаляем лишние пробелы и символы переноса строк
323
+
324
+ # Заменяем множественные переносы строк на двойные (для разделения абзацев)
325
+ result_text = re.sub(r'\n{3,}', '\n\n', result_text)
326
+
327
+ # Заменяем множественные пробелы на одиночные
328
+ result_text = re.sub(r' +', ' ', result_text)
329
+
330
+ # Убираем пробелы перед знаками препинания
331
+ result_text = re.sub(r' ([.,!?:;)])', r'\1', result_text)
332
+
333
+ # Убираем пробелы перед переносами строк и после переносов строк
334
+ result_text = re.sub(r' +\n', '\n', result_text)
335
+ result_text = re.sub(r'\n +', '\n', result_text)
336
+
337
+ # Убираем лишние переносы строк и пробелы в начале и конце текста
338
+ result_text = result_text.strip()
339
+
340
+ return result_text
341
+
342
+ def _get_sorted_chunks(
343
+ self, chunks: list[LinkerEntity], links: list[LinkerEntity]
344
+ ) -> list[LinkerEntity]:
345
+ """
346
+ Получает отсортированные чанки на основе связей или поля chunk_index.
347
+
348
+ Args:
349
+ chunks: Список чанков для сортировки
350
+ links: Список связей для определения порядка
351
+
352
+ Returns:
353
+ Отсортированные чанки
354
+ """
355
+ # Сортируем чанки по порядку в связях
356
+ if links:
357
+ # Получаем словарь для быстрого доступа к чанкам по ID
358
+ chunk_dict = {c.id: c for c in chunks}
359
+
360
+ # Сортируем по порядку в связях
361
+ sorted_chunks = []
362
+ for link in sorted(links, key=lambda l: l.number_in_relation or 0):
363
+ if link.target_id in chunk_dict:
364
+ sorted_chunks.append(chunk_dict[link.target_id])
365
+
366
+ return sorted_chunks
367
+
368
+ # Если нет связей, сортируем по chunk_index
369
+ return sorted(chunks, key=lambda c: c.chunk_index or 0)
370
+
371
+ def _prepare_document(self, document: ParsedDocument | str) -> ParsedDocument:
372
+ """
373
+ Обрабатывает входные данные и возвращает ParsedDocument.
374
+
375
+ Args:
376
+ document: Документ (ParsedDocument или текст)
377
+
378
+ Returns:
379
+ Обработанный документ типа ParsedDocument
380
+ """
381
+ if isinstance(document, ParsedDocument):
382
+ return document
383
+ elif isinstance(document, str):
384
+ # Простая обработка текстового документа
385
+ return ParsedDocument(
386
+ paragraphs=[
387
+ ParsedTextBlock(text=paragraph)
388
+ for paragraph in document.split('\n')
389
+ ]
390
+ )
391
+
392
+ def _extract_words(self, doc: ParsedDocument) -> list[str]:
393
+ """
394
+ Извлекает все слова из документа.
395
+
396
+ Args:
397
+ doc: Документ для извлечения слов
398
+
399
+ Returns:
400
+ Список слов документа
401
+ """
402
+ words = []
403
+ for paragraph in doc.paragraphs:
404
+ # Добавляем слова из параграфа
405
+ paragraph_words = paragraph.text.split()
406
+ words.extend(paragraph_words)
407
+ # Добавляем маркер конца параграфа как отдельный элемент
408
+ words.append("\n")
409
+ return words
410
+
411
+ def _ensure_document_entity(
412
+ self,
413
+ doc: ParsedDocument,
414
+ doc_entity: LinkerEntity | None,
415
+ ) -> LinkerEntity:
416
+ """
417
+ Создает сущность документа, если не предоставлена.
418
+
419
+ Args:
420
+ doc: Документ
421
+ doc_entity: Сущность документа (может быть None)
422
+
423
+ Returns:
424
+ Сущность документа
425
+ """
426
+ if doc_entity is None:
427
+ return LinkerEntity(
428
+ id=uuid4(),
429
+ name=doc.name,
430
+ text=doc.name,
431
+ metadata={"type": doc.type},
432
+ type="Document",
433
+ )
434
+ return doc_entity
435
+
436
+ def _calculate_step(self) -> int:
437
+ """
438
+ Вычисляет шаг для создания чанков.
439
+
440
+ Returns:
441
+ Размер шага между началами чанков
442
+ """
443
+ return self.params.words_per_chunk - self.params.overlap_words
444
+
445
+ def _prepare_chunk_text(
446
+ self,
447
+ words: list[str],
448
+ start_idx: int,
449
+ length: int,
450
+ ) -> str:
451
+ """
452
+ Подготавливает текст чанка и текст для поиска.
453
+
454
+ Args:
455
+ words: Список слов документа
456
+ start_idx: Индекс начала чанка
457
+ end_idx: Длина текста в словах
458
+
459
+ Returns:
460
+ Итоговый текст
461
+ """
462
+ # Извлекаем текст чанка без нахлеста с сохранением структуры параграфов
463
+ end_idx = min(start_idx + length, len(words))
464
+ chunk_words = words[start_idx:end_idx]
465
+ chunk_text = ""
466
+
467
+ for word in chunk_words:
468
+ if word == "\n":
469
+ # Если это маркер конца параграфа, добавляем перенос строки
470
+ chunk_text += "\n"
471
+ else:
472
+ # Иначе добавляем слово с пробелом
473
+ if chunk_text and chunk_text[-1] != "\n":
474
+ chunk_text += " "
475
+ chunk_text += word
476
+
477
+ return chunk_text
478
+
479
+ def _create_chunk(
480
+ self,
481
+ chunk_text: str,
482
+ in_search_text: str,
483
+ start_idx: int,
484
+ end_idx: int,
485
+ chunk_index: int,
486
+ words: list[str],
487
+ total_words: int,
488
+ doc_name: str,
489
+ ) -> FixedSizeChunk:
490
+ """
491
+ Создает чанк фиксированного размера.
492
+
493
+ Args:
494
+ chunk_text: Текст чанка без нахлеста
495
+ in_search_text: Текст чанка с нахлестом
496
+ start_idx: Индекс первого слова в чанке
497
+ end_idx: Индекс последнего слова в чанке
498
+ chunk_index: Индекс чанка в документе
499
+ words: Список всех слов документа
500
+ total_words: Общее количество слов в документе
501
+ doc_name: Имя документа
502
+
503
+ Returns:
504
+ FixedSizeChunk: Созданный чанк
505
+ """
506
+ # Определяем нахлесты без учета границ предложений
507
+ overlap_left = " ".join(
508
+ words[max(0, start_idx - self.params.overlap_words) : start_idx]
509
+ )
510
+ overlap_right = " ".join(
511
+ words[end_idx : min(total_words, end_idx + self.params.overlap_words)]
512
+ )
513
+
514
+ # Определяем границы предложений
515
+ left_sentence_part = ""
516
+ right_sentence_part = ""
517
+
518
+ if self.params.respect_sentence_boundaries:
519
+ # Находим ближайшую границу предложения слева
520
+ left_text = " ".join(
521
+ words[max(0, start_idx - self.params.overlap_words) : start_idx]
522
+ )
523
+ left_sentence_part = self._find_sentence_boundary(left_text, True)
524
+
525
+ # Находим ближайшую границу предложения справа
526
+ right_text = " ".join(
527
+ words[end_idx : min(total_words, end_idx + self.params.overlap_words)]
528
+ )
529
+ right_sentence_part = self._find_sentence_boundary(right_text, False)
530
+
531
+ # Создаем чанк с учетом границ предложений
532
+ return FixedSizeChunk(
533
+ id=uuid4(),
534
+ name=f"{doc_name}_chunk_{chunk_index}",
535
+ text=chunk_text,
536
+ chunk_index=chunk_index,
537
+ in_search_text=in_search_text,
538
+ token_count=end_idx - start_idx + 1,
539
+ left_sentence_part=left_sentence_part,
540
+ right_sentence_part=right_sentence_part,
541
+ overlap_left=overlap_left,
542
+ overlap_right=overlap_right,
543
+ metadata={},
544
+ type=FixedSizeChunk.__name__,
545
+ )
546
+
547
+ def _create_link(
548
+ self, doc_entity: LinkerEntity, chunk: LinkerEntity
549
+ ) -> LinkerEntity:
550
+ """
551
+ Создает связь между документом и чанком.
552
+
553
+ Args:
554
+ doc_entity: Сущность документа
555
+ chunk: Сущность чанка
556
+
557
+ Returns:
558
+ Объект связи
559
+ """
560
+ return LinkerEntity(
561
+ id=uuid4(),
562
+ name="document_to_chunk",
563
+ text="",
564
+ metadata={},
565
+ source_id=doc_entity.id,
566
+ target_id=chunk.id,
567
+ type="Link",
568
+ )
lib/extractor/ntr_text_fragmentation/core/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Основные классы для разбиения и сборки документов.
3
+ """
4
+
5
+ from .destructurer import Destructurer
6
+ from .entity_repository import EntityRepository, InMemoryEntityRepository
7
+ from .injection_builder import InjectionBuilder
8
+
9
+ __all__ = ["Destructurer", "InjectionBuilder", "EntityRepository", "InMemoryEntityRepository"]
lib/extractor/ntr_text_fragmentation/core/destructurer.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль для деструктуризации документа.
3
+ """
4
+
5
+ from uuid import uuid4
6
+
7
+ # Внешние импорты
8
+ from ntr_fileparser import ParsedDocument
9
+
10
+ # Импорты из этой же библиотеки
11
+ from ..additors.tables_processor import TablesProcessor
12
+ from ..chunking.chunking_strategy import ChunkingStrategy
13
+ from ..chunking.specific_strategies.fixed_size_chunking import \
14
+ FixedSizeChunkingStrategy
15
+ from ..models import DocumentAsEntity, LinkerEntity
16
+
17
+
18
+ class Destructurer:
19
+ """
20
+ Класс для подготовки документа для загрузки в базу данных.
21
+ Разбивает документ на чанки, создает связи между ними и
22
+ извлекает вспомогательные сущности.
23
+ """
24
+
25
+ # Доступные стратегии чанкинга
26
+ STRATEGIES: dict[str, type[ChunkingStrategy]] = {
27
+ "fixed_size": FixedSizeChunkingStrategy,
28
+ }
29
+
30
+ def __init__(
31
+ self,
32
+ document: ParsedDocument,
33
+ strategy_name: str = "fixed_size",
34
+ process_tables: bool = True,
35
+ **kwargs,
36
+ ):
37
+ """
38
+ Инициализация деструктуризатора.
39
+
40
+ Args:
41
+ document: Документ для обработки
42
+ strategy_name: Имя стратегии
43
+ process_tables: Флаг обработки таблиц
44
+ **kwargs: Параметры для стратегии
45
+ """
46
+ self.document = document
47
+ self.strategy: ChunkingStrategy | None = None
48
+ self.process_tables = process_tables
49
+ # Инициализируем процессор таблиц, если нужно
50
+ self.tables_processor = TablesProcessor() if process_tables else None
51
+ # Кеш для хранения созданных стратегий
52
+ self._strategy_cache: dict[str, ChunkingStrategy] = {}
53
+
54
+ # Конфигурируем стратегию
55
+ self.configure(strategy_name, **kwargs)
56
+
57
+ def configure(self, strategy_name: str = "fixed_size", **kwargs) -> None:
58
+ """
59
+ Установка стратегии чанкинга.
60
+
61
+ Args:
62
+ strategy_name: Имя стратегии
63
+ **kwargs: Параметры для стратегии
64
+
65
+ Raises:
66
+ ValueError: Если указана неизвестная стратегия
67
+ """
68
+ # Получаем класс стратегии из словаря доступных стратегий
69
+ if strategy_name not in self.STRATEGIES:
70
+ raise ValueError(f"Неизвестная стратегия: {strategy_name}")
71
+
72
+ # Создаем ключ кеша на основе имени стратегии и параметров
73
+ cache_key = f"{strategy_name}_{hash(frozenset(kwargs.items()))}"
74
+
75
+ # Проверяем, есть ли стратегия в кеше
76
+ if cache_key in self._strategy_cache:
77
+ self.strategy = self._strategy_cache[cache_key]
78
+ return
79
+
80
+ # Создаем экземпляр стратегии с переданными параметрами
81
+ strategy_class = self.STRATEGIES[strategy_name]
82
+ self.strategy = strategy_class(**kwargs)
83
+
84
+ # Сохраняем стратегию в кеше
85
+ self._strategy_cache[cache_key] = self.strategy
86
+
87
+ def destructure(self) -> list[LinkerEntity]:
88
+ """
89
+ Основной метод деструктуризации.
90
+ Разбивает документ на чанки и создает связи.
91
+
92
+ Returns:
93
+ list[LinkerEntity]: список сущностей, включая связи
94
+
95
+ Raises:
96
+ RuntimeError: Если стратегия не была сконфигурирована
97
+ """
98
+ # Проверяем, что стратегия сконфигурирована
99
+ if self.strategy is None:
100
+ raise RuntimeError("Стратегия не была сконфигурирована")
101
+
102
+ # Создаем сущность документа с метаданными
103
+ doc_entity = self._create_document_entity()
104
+
105
+ # Применяем стратегию чанкинга
106
+ entities = self.strategy.chunk(self.document, doc_entity)
107
+
108
+ # Обрабатываем таблицы, если это включено
109
+ if self.process_tables and self.tables_processor and self.document.tables:
110
+ table_entities = self.tables_processor.process(self.document, doc_entity)
111
+ entities.extend(table_entities)
112
+
113
+ # Сериализуем все сущности в простейшую форму LinkerEntity
114
+ serialized_entities = [entity.serialize() for entity in entities]
115
+
116
+ return serialized_entities
117
+
118
+ def _create_document_entity(self) -> DocumentAsEntity:
119
+ """
120
+ Создает сущность документа с метаданными.
121
+
122
+ Returns:
123
+ DocumentAsEntity: сущность документа
124
+ """
125
+ # Получаем имя документа или используем значение по умолчанию
126
+ doc_name = self.document.name or "Document"
127
+
128
+ # Создаем метаданные, включая информацию о стратегии чанкинга
129
+ metadata = {
130
+ "type": self.document.type,
131
+ "chunking_strategy": (
132
+ self.strategy.__class__.__name__ if self.strategy else "unknown"
133
+ ),
134
+ }
135
+
136
+ # Создаем сущность документа
137
+ return DocumentAsEntity(
138
+ id=uuid4(),
139
+ name=doc_name,
140
+ text="",
141
+ metadata=metadata,
142
+ type="Document",
143
+ )
lib/extractor/ntr_text_fragmentation/core/entity_repository.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Интерфейс репозитория сущностей.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections import defaultdict
7
+ from typing import Iterable
8
+ from uuid import UUID
9
+
10
+ from ..models import Chunk, LinkerEntity
11
+ from ..models.document import DocumentAsEntity
12
+
13
+
14
+ class EntityRepository(ABC):
15
+ """
16
+ Абстрактный интерфейс для доступа к хранилищу сущностей.
17
+ Позволяет InjectionBuilder получать нужные сущности независимо от их хранилища.
18
+
19
+ Этот интерфейс определяет только методы для получения сущностей.
20
+ Логика сохранения и изменения сущностей остается за пределами этого интерфейса
21
+ и должна быть реализована в конкретных классах, расширяющих данный интерфейс.
22
+ """
23
+
24
+ @abstractmethod
25
+ def get_entities_by_ids(self, entity_ids: Iterable[UUID]) -> list[LinkerEntity]:
26
+ """
27
+ Получить сущности по списку идентификаторов.
28
+
29
+ Args:
30
+ entity_ids: Список идентификаторов сущностей
31
+
32
+ Returns:
33
+ Список сущностей, соответствующих указанным идентификаторам
34
+ """
35
+ pass
36
+
37
+ @abstractmethod
38
+ def get_document_for_chunks(self, chunk_ids: Iterable[UUID]) -> list[LinkerEntity]:
39
+ """
40
+ Получить документы, которым принадлежат указанные чанки.
41
+
42
+ Args:
43
+ chunk_ids: Список идентификаторов чанков
44
+
45
+ Returns:
46
+ Список документов, которым принадлежат указанные чанки
47
+ """
48
+ pass
49
+
50
+ @abstractmethod
51
+ def get_neighboring_chunks(self,
52
+ chunk_ids: Iterable[UUID],
53
+ max_distance: int = 1) -> list[LinkerEntity]:
54
+ """
55
+ Получить соседние чанки для указанных чанков.
56
+
57
+ Args:
58
+ chunk_ids: Список идентификаторов чанков
59
+ max_distance: Максимальное расстояние до соседа
60
+
61
+ Returns:
62
+ Список соседних чанков
63
+ """
64
+ pass
65
+
66
+ @abstractmethod
67
+ def get_related_entities(self,
68
+ entity_ids: Iterable[UUID],
69
+ relation_name: str | None = None,
70
+ as_source: bool = False,
71
+ as_target: bool = False) -> list[LinkerEntity]:
72
+ """
73
+ Получить сущности, связанные с указанными сущностями.
74
+
75
+ Args:
76
+ entity_ids: Список идентификаторов сущностей
77
+ relation_name: Опциональное имя отношения для фильтрации
78
+ as_source: Если True, ищем связи, где указанные entity_ids являются
79
+ источниками (source_id)
80
+ as_target: Если True, ищем связи, где указанные entity_ids являются
81
+ целевыми (target_id)
82
+
83
+ Returns:
84
+ Список связанных сущностей и связей
85
+ """
86
+ pass
87
+
88
+
89
+ class InMemoryEntityRepository(EntityRepository):
90
+ """
91
+ Реализация EntityRepository, хранящая все сущности в памяти.
92
+ Обеспечивает обратную совместимость и используется для тестирования.
93
+ """
94
+
95
+ def __init__(self, entities: list[LinkerEntity] | None = None):
96
+ """
97
+ Инициализация репозитория с начальным списком сущностей.
98
+
99
+ Args:
100
+ entities: Начальный список сущностей
101
+ """
102
+ self.entities = entities or []
103
+ self._build_indices()
104
+
105
+ def _build_indices(self) -> None:
106
+ """
107
+ Строит индексы для быстрого доступа к сущностям.
108
+ """
109
+ self.entities_by_id = {e.id: e for e in self.entities}
110
+ self.chunks = [e for e in self.entities if isinstance(e, Chunk)]
111
+ self.docs = [e for e in self.entities if isinstance(e, DocumentAsEntity)]
112
+
113
+ # Индексы для быстрого поиска связей
114
+ self.doc_to_chunks = defaultdict(list)
115
+ self.chunk_to_doc = {}
116
+ self.entity_relations = defaultdict(list)
117
+ self.entity_targets = defaultdict(list)
118
+
119
+ # Заполняем индексы
120
+ for e in self.entities:
121
+ if e.is_link():
122
+ self.entity_relations[e.source_id].append(e)
123
+ self.entity_targets[e.target_id].append(e)
124
+ if e.name == "document_to_chunk":
125
+ self.doc_to_chunks[e.source_id].append(e.target_id)
126
+ self.chunk_to_doc[e.target_id] = e.source_id
127
+ if e.name == "document_to_table":
128
+ self.entity_relations
129
+ self.entity_targets[e.source_id].append(e.target_id)
130
+
131
+ # Этот метод не является частью интерфейса EntityRepository,
132
+ # но он полезен для тестирования и реализации обратной совместимости
133
+ def add_entities(self, entities: list[LinkerEntity]) -> None:
134
+ """
135
+ Добавляет сущности в репозиторий.
136
+
137
+ Примечание: Этот метод не является частью интерфейса EntityRepository.
138
+ Он добавлен для удобства тестирования и обратной совместимости.
139
+
140
+ Args:
141
+ entities: Список сущностей для добавления
142
+ """
143
+ self.entities.extend(entities)
144
+ self._build_indices()
145
+
146
+ def get_entities_by_ids(self, entity_ids: Iterable[UUID]) -> list[LinkerEntity]:
147
+ result = [self.entities_by_id.get(eid) for eid in entity_ids if eid in self.entities_by_id]
148
+ return result
149
+
150
+ def get_document_for_chunks(self, chunk_ids: Iterable[UUID]) -> list[LinkerEntity]:
151
+ result = []
152
+ for chunk_id in chunk_ids:
153
+ doc_id = self.chunk_to_doc.get(chunk_id)
154
+ if doc_id and doc_id in self.entities_by_id:
155
+ doc = self.entities_by_id[doc_id]
156
+ if doc not in result:
157
+ result.append(doc)
158
+ return result
159
+
160
+ def get_neighboring_chunks(self,
161
+ chunk_ids: Iterable[UUID],
162
+ max_distance: int = 1) -> list[LinkerEntity]:
163
+ result = []
164
+ chunk_indices = {}
165
+
166
+ # Сначала собираем индексы всех указанных чанков
167
+ for chunk_id in chunk_ids:
168
+ if chunk_id in self.entities_by_id:
169
+ chunk = self.entities_by_id[chunk_id]
170
+ if hasattr(chunk, 'chunk_index') and chunk.chunk_index is not None:
171
+ chunk_indices[chunk_id] = chunk.chunk_index
172
+
173
+ # Если нет чанков с индексами, возвращаем пустой список
174
+ if not chunk_indices:
175
+ return []
176
+
177
+ # Затем для каждого документа находим соседние чанки
178
+ for doc_id, doc_chunk_ids in self.doc_to_chunks.items():
179
+ # Проверяем, принадлежит ли хоть один из чанков этому документу
180
+ has_chunks = any(chunk_id in doc_chunk_ids for chunk_id in chunk_ids)
181
+ if not has_chunks:
182
+ continue
183
+
184
+ # Для каждого чанка в документе проверяем, является ли он соседом
185
+ for doc_chunk_id in doc_chunk_ids:
186
+ if doc_chunk_id in self.entities_by_id:
187
+ chunk = self.entities_by_id[doc_chunk_id]
188
+
189
+ # Если у чанка нет индекса, пропускаем его
190
+ if not hasattr(chunk, 'chunk_index') or chunk.chunk_index is None:
191
+ continue
192
+
193
+ # Проверяем, является ли чанк соседом какого-либо из исходных чанков
194
+ for orig_chunk_id, orig_index in chunk_indices.items():
195
+ if abs(chunk.chunk_index - orig_index) <= max_distance and doc_chunk_id not in chunk_ids:
196
+ result.append(chunk)
197
+ break
198
+
199
+ return result
200
+
201
+ def get_related_entities(self,
202
+ entity_ids: Iterable[UUID],
203
+ relation_name: str | None = None,
204
+ as_source: bool = False,
205
+ as_target: bool = False) -> list[LinkerEntity]:
206
+ """
207
+ Получить сущности, связанные с указанными сущностями.
208
+
209
+ Args:
210
+ entity_ids: Список идентификаторов сущностей
211
+ relation_name: Опциональное имя отношения для фильтрации
212
+ as_source: Если True, ищем связи, где указанные entity_ids являются источниками
213
+ as_target: Если True, ищем связи, где указанные entity_ids являются целями
214
+
215
+ Returns:
216
+ Список связанных сущностей и связей
217
+ """
218
+ result = []
219
+
220
+ # Если не указано ни as_source, ни as_target, по умолчанию ищем связи,
221
+ # где указанные entity_ids являются источниками
222
+ if not as_source and not as_target:
223
+ as_source = True
224
+
225
+ for entity_id in entity_ids:
226
+ if as_source:
227
+ # Ищем связи, где сущность является источником
228
+ relations = self.entity_relations.get(entity_id, [])
229
+
230
+ for link in relations:
231
+ if relation_name is None or link.name == relation_name:
232
+ # Добавляем саму связь
233
+ if link not in result:
234
+ result.append(link)
235
+
236
+ # Добавляем целевую сущность
237
+ if link.target_id in self.entities_by_id:
238
+ related_entity = self.entities_by_id[link.target_id]
239
+ if related_entity not in result:
240
+ result.append(related_entity)
241
+
242
+ if as_target:
243
+ # Ищем связи, где сущность является целью
244
+ relations = self.entity_targets.get(entity_id, [])
245
+
246
+ for link in relations:
247
+ if relation_name is None or link.name == relation_name:
248
+ # Добавляем саму связь
249
+ if link not in result:
250
+ result.append(link)
251
+
252
+ # Добавляем исходную сущность
253
+ if link.source_id in self.entities_by_id:
254
+ related_entity = self.entities_by_id[link.source_id]
255
+ if related_entity not in result:
256
+ result.append(related_entity)
257
+
258
+ return result
lib/extractor/ntr_text_fragmentation/core/injection_builder.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Класс для сборки документа из чанков.
3
+ """
4
+
5
+ from collections import defaultdict
6
+ from typing import Optional, Type
7
+ from uuid import UUID
8
+
9
+ from ..chunking.chunking_strategy import ChunkingStrategy
10
+ from ..models.chunk import Chunk
11
+ from ..models.linker_entity import LinkerEntity
12
+ from .entity_repository import EntityRepository, InMemoryEntityRepository
13
+
14
+
15
+ class InjectionBuilder:
16
+ """
17
+ Класс для сборки документов из чанков и связей.
18
+
19
+ Отвечает за:
20
+ - Сборку текста из чанков с учетом порядка
21
+ - Ранжирование документов на основе весов чанков
22
+ - Добавление соседних чанков для улучшения сборки
23
+ - Сборку данных из таблиц и других сущностей
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ repository: EntityRepository | None = None,
29
+ entities: list[LinkerEntity] | None = None,
30
+ ):
31
+ """
32
+ Инициализация сборщика инъекций.
33
+
34
+ Args:
35
+ repository: Репозиторий сущностей (если None, используется InMemoryEntityRepository)
36
+ entities: Список всех сущностей (опционально, для обратной совместимости)
37
+ """
38
+ # Для обратной совместимости
39
+ if repository is None and entities is not None:
40
+ repository = InMemoryEntityRepository(entities)
41
+
42
+ self.repository = repository or InMemoryEntityRepository()
43
+ self.strategy_map: dict[str, Type[ChunkingStrategy]] = {}
44
+
45
+ def register_strategy(
46
+ self,
47
+ doc_type: str,
48
+ strategy: Type[ChunkingStrategy],
49
+ ) -> None:
50
+ """
51
+ Регистрирует стратегию для определенного типа документа.
52
+
53
+ Args:
54
+ doc_type: Тип документа
55
+ strategy: Стратегия чанкинга
56
+ """
57
+ self.strategy_map[doc_type] = strategy
58
+
59
+ def build(
60
+ self,
61
+ filtered_entities: list[LinkerEntity] | list[UUID],
62
+ chunk_scores: dict[str, float] | None = None,
63
+ include_tables: bool = True,
64
+ max_documents: Optional[int] = None,
65
+ ) -> str:
66
+ """
67
+ Собирает текст из всех документов, связанных с предоставленными чанками.
68
+
69
+ Args:
70
+ filtered_entities: Список чанков или их идентификаторов
71
+ chunk_scores: Словарь весов чанков {chunk_id: score}
72
+ include_tables: Флаг для включения таблиц в результат
73
+ max_documents: Максимальное количество документов (None = все)
74
+
75
+ Returns:
76
+ Собранный текст со всеми документами
77
+ """
78
+ # Преобразуем входные данные в список идентификаторов
79
+ entity_ids = [
80
+ entity.id if isinstance(entity, LinkerEntity) else entity
81
+ for entity in filtered_entities
82
+ ]
83
+
84
+ print(f"entity_ids: {entity_ids[:3]}...{entity_ids[-3:]}")
85
+
86
+ if not entity_ids:
87
+ return ""
88
+
89
+ # Получаем сущности по их идентификаторам
90
+ entities = self.repository.get_entities_by_ids(entity_ids)
91
+
92
+ print(f"entities: {entities[:3]}...{entities[-3:]}")
93
+
94
+ # Десериализуем сущности в их специализированные типы
95
+ deserialized_entities = []
96
+ for entity in entities:
97
+ # Используем статический метод десериализации
98
+ deserialized_entity = LinkerEntity.deserialize(entity)
99
+ deserialized_entities.append(deserialized_entity)
100
+
101
+ print(f"deserialized_entities: {deserialized_entities[:3]}...{deserialized_entities[-3:]}")
102
+
103
+ # Фильтруем сущности на чанки и таблицы
104
+ chunks = [e for e in deserialized_entities if "Chunk" in e.type]
105
+ tables = [e for e in deserialized_entities if "Table" in e.type]
106
+
107
+ # Группируем таблицы по документам
108
+ table_ids = {table.id for table in tables}
109
+ doc_tables = self._group_tables_by_document(table_ids)
110
+
111
+ if not chunks and not tables:
112
+ return ""
113
+
114
+ # Получаем идентификаторы чанков
115
+ chunk_ids = [chunk.id for chunk in chunks]
116
+
117
+ # Получаем связи для чанков (чанки являются целями связей)
118
+ links = self.repository.get_related_entities(
119
+ chunk_ids,
120
+ relation_name="document_to_chunk",
121
+ as_target=True,
122
+ )
123
+
124
+ print(f"links: {links[:3]}...{links[-3:]}")
125
+
126
+ # Группируем чанки по документам
127
+ doc_chunks = self._group_chunks_by_document(chunks, links)
128
+
129
+ print(f"doc_chunks: {doc_chunks}")
130
+
131
+ # Получаем все документы для чанков и таблиц
132
+ doc_ids = set(doc_chunks.keys()) | set(doc_tables.keys())
133
+ docs = self.repository.get_entities_by_ids(doc_ids)
134
+
135
+ # Десериализуем документы
136
+ deserialized_docs = []
137
+ for doc in docs:
138
+ deserialized_doc = LinkerEntity.deserialize(doc)
139
+ deserialized_docs.append(deserialized_doc)
140
+
141
+ print(f"deserialized_docs: {deserialized_docs[:3]}...{deserialized_docs[-3:]}")
142
+
143
+ # Вычисляем веса документов на основе весов чанков
144
+ doc_scores = self._calculate_document_scores(doc_chunks, chunk_scores)
145
+
146
+ # Сортируем документы по весам (по убыванию)
147
+ sorted_docs = sorted(
148
+ deserialized_docs,
149
+ key=lambda d: doc_scores.get(str(d.id), 0.0),
150
+ reverse=True
151
+ )
152
+
153
+ print(f"sorted_docs: {sorted_docs[:3]}...{sorted_docs[-3:]}")
154
+
155
+ # Ограничиваем количество документов, если указано
156
+ if max_documents:
157
+ sorted_docs = sorted_docs[:max_documents]
158
+
159
+ print(f"sorted_docs: {sorted_docs[:3]}...{sorted_docs[-3:]}")
160
+
161
+ # Собираем текст для каждого документа
162
+ result_parts = []
163
+ for doc in sorted_docs:
164
+ doc_text = self._build_document_text(
165
+ doc,
166
+ doc_chunks.get(doc.id, []),
167
+ doc_tables.get(doc.id, []),
168
+ include_tables
169
+ )
170
+ if doc_text:
171
+ result_parts.append(doc_text)
172
+
173
+ # Объединяем результаты
174
+ return "\n\n".join(result_parts)
175
+
176
+ def _build_document_text(
177
+ self,
178
+ doc: LinkerEntity,
179
+ chunks: list[LinkerEntity],
180
+ tables: list[LinkerEntity],
181
+ include_tables: bool
182
+ ) -> str:
183
+ """
184
+ Собирает текст документа из чанков и таблиц.
185
+
186
+ Args:
187
+ doc: Сущность документа
188
+ chunks: Список чанков документа
189
+ tables: Список таблиц документа
190
+ include_tables: Флаг для включения таблиц
191
+
192
+ Returns:
193
+ Собранный текст документа
194
+ """
195
+ # Получаем стратегию чанкинга
196
+ strategy_name = doc.metadata.get("chunking_strategy", "fixed_size")
197
+ strategy = self._get_strategy_instance(strategy_name)
198
+
199
+ # Собираем текст из чанков
200
+ chunks_text = strategy.dechunk(chunks, self.repository) if chunks else ""
201
+
202
+ # Собираем текст из таблиц, если нужно
203
+ tables_text = ""
204
+ if include_tables and tables:
205
+ # Сортируем таблицы по индексу, если он есть
206
+ sorted_tables = sorted(
207
+ tables,
208
+ key=lambda t: t.metadata.get("table_index", 0) if t.metadata else 0
209
+ )
210
+
211
+ # Собираем текст таблиц
212
+ tables_text = "\n\n".join(table.text for table in sorted_tables if hasattr(table, 'text'))
213
+
214
+ # Формируем результат
215
+ result = f"[Источник] - {doc.name}\n"
216
+ if chunks_text:
217
+ result += chunks_text
218
+ if tables_text:
219
+ if chunks_text:
220
+ result += "\n\n"
221
+ result += tables_text
222
+
223
+ return result
224
+
225
+ def _group_chunks_by_document(
226
+ self,
227
+ chunks: list[LinkerEntity],
228
+ links: list[LinkerEntity]
229
+ ) -> dict[UUID, list[LinkerEntity]]:
230
+ """
231
+ Группирует чанки по документам.
232
+
233
+ Args:
234
+ chunks: Список чанков
235
+ links: Список связей между документами и чанками
236
+
237
+ Returns:
238
+ Словарь {doc_id: [chunks]}
239
+ """
240
+ result = defaultdict(list)
241
+
242
+ # Создаем словарь для быстрого доступа к чанкам по ID
243
+ chunk_dict = {chunk.id: chunk for chunk in chunks}
244
+
245
+ # Группируем чанки по документам на основе связей
246
+ for link in links:
247
+ if link.target_id in chunk_dict and link.source_id:
248
+ result[link.source_id].append(chunk_dict[link.target_id])
249
+
250
+ return result
251
+
252
+ def _group_tables_by_document(
253
+ self,
254
+ table_ids: set[UUID]
255
+ ) -> dict[UUID, list[LinkerEntity]]:
256
+ """
257
+ Группирует таблицы по документам.
258
+
259
+ Args:
260
+ table_ids: Множество идентификаторов таблиц
261
+
262
+ Returns:
263
+ Словарь {doc_id: [tables]}
264
+ """
265
+ result = defaultdict(list)
266
+
267
+ table_ids = [str(table_id) for table_id in table_ids]
268
+
269
+ # Получаем связи для таблиц (таблицы являются целями связей)
270
+ if not table_ids:
271
+ return result
272
+
273
+ links = self.repository.get_related_entities(
274
+ table_ids,
275
+ relation_name="document_to_table",
276
+ as_target=True,
277
+ )
278
+
279
+ # Получаем сами таблицы
280
+ tables = self.repository.get_entities_by_ids(table_ids)
281
+
282
+ # Десериализуем таблицы
283
+ deserialized_tables = []
284
+ for table in tables:
285
+ deserialized_table = LinkerEntity.deserialize(table)
286
+ deserialized_tables.append(deserialized_table)
287
+
288
+ # Создаем словарь для быстрого доступа к таблицам по ID
289
+ table_dict = {str(table.id): table for table in deserialized_tables}
290
+
291
+ # Группируем таблицы по документам на основе связей
292
+ for link in links:
293
+ if link.target_id in table_dict and link.source_id:
294
+ result[link.source_id].append(table_dict[link.target_id])
295
+
296
+ return result
297
+
298
+ def _calculate_document_scores(
299
+ self,
300
+ doc_chunks: dict[UUID, list[LinkerEntity]],
301
+ chunk_scores: Optional[dict[str, float]]
302
+ ) -> dict[str, float]:
303
+ """
304
+ Вычисляет веса документов на основе весов чанков.
305
+
306
+ Args:
307
+ doc_chunks: Словарь {doc_id: [chunks]}
308
+ chunk_scores: Словарь весов чанков {chunk_id: score}
309
+
310
+ Returns:
311
+ Словарь весов документов {doc_id: score}
312
+ """
313
+ if not chunk_scores:
314
+ return {str(doc_id): 1.0 for doc_id in doc_chunks.keys()}
315
+
316
+ result = {}
317
+ for doc_id, chunks in doc_chunks.items():
318
+ # Берем максимальный вес среди чанков документа
319
+ chunk_weights = [chunk_scores.get(str(c.id), 0.0) for c in chunks]
320
+ result[str(doc_id)] = max(chunk_weights) if chunk_weights else 0.0
321
+
322
+ return result
323
+
324
+ def add_neighboring_chunks(
325
+ self, entities: list[LinkerEntity] | list[UUID], max_distance: int = 1
326
+ ) -> list[LinkerEntity]:
327
+ """
328
+ Добавляет соседние чанки к отфильтрованному списку чанков.
329
+
330
+ Args:
331
+ entities: Список сущностей или их идентификаторов
332
+ max_distance: Максимальное расстояние для поиска соседей
333
+
334
+ Returns:
335
+ Расширенный список сущностей
336
+ """
337
+ # Преобразуем входные данные в список идентификаторов
338
+ entity_ids = [
339
+ entity.id if isinstance(entity, LinkerEntity) else entity
340
+ for entity in entities
341
+ ]
342
+
343
+ if not entity_ids:
344
+ return []
345
+
346
+ # Получаем исходные сущности
347
+ original_entities = self.repository.get_entities_by_ids(entity_ids)
348
+
349
+ # Фильтруем только чанки
350
+ chunk_entities = [e for e in original_entities if isinstance(e, Chunk)]
351
+
352
+ if not chunk_entities:
353
+ return original_entities
354
+
355
+ # Получаем идентификаторы чанков
356
+ chunk_ids = [chunk.id for chunk in chunk_entities]
357
+
358
+ # Получаем соседние чанки
359
+ neighboring_chunks = self.repository.get_neighboring_chunks(
360
+ chunk_ids, max_distance
361
+ )
362
+
363
+ # Объединяем исходные сущности с соседними чанками
364
+ result = list(original_entities)
365
+ for chunk in neighboring_chunks:
366
+ if chunk not in result:
367
+ result.append(chunk)
368
+
369
+ # Получаем документы и связи для всех чанков
370
+ all_chunk_ids = [chunk.id for chunk in result if isinstance(chunk, Chunk)]
371
+
372
+ docs = self.repository.get_document_for_chunks(all_chunk_ids)
373
+ links = self.repository.get_related_entities(
374
+ all_chunk_ids, relation_name="document_to_chunk", as_target=True
375
+ )
376
+
377
+ # Добавляем документы и связи в результат
378
+ for doc in docs:
379
+ if doc not in result:
380
+ result.append(doc)
381
+
382
+ for link in links:
383
+ if link not in result:
384
+ result.append(link)
385
+
386
+ return result
387
+
388
+ def _get_strategy_instance(self, strategy_name: str) -> ChunkingStrategy:
389
+ """
390
+ Создает экземпляр стратегии чанкинга по имени.
391
+
392
+ Args:
393
+ strategy_name: Имя стратегии
394
+
395
+ Returns:
396
+ Экземпляр соответствующей стратегии
397
+ """
398
+ # Используем словарь для маппинга имен стратегий на их классы
399
+ strategies = {
400
+ "fixed_size": "..chunking.specific_strategies.fixed_size_chunking.FixedSizeChunkingStrategy",
401
+ }
402
+
403
+ # Если стратегия зарегистрирована в self.strategy_map, используем её
404
+ if strategy_name in self.strategy_map:
405
+ return self.strategy_map[strategy_name]()
406
+
407
+ # Если стратегия известна, импортируем и инициализируем её
408
+ if strategy_name in strategies:
409
+ import importlib
410
+
411
+ module_path, class_name = strategies[strategy_name].rsplit(".", 1)
412
+ try:
413
+ # Конвертируем относительный путь в абсолютный
414
+ abs_module_path = f"ntr_text_fragmentation{module_path[2:]}"
415
+ module = importlib.import_module(abs_module_path)
416
+ strategy_class = getattr(module, class_name)
417
+ return strategy_class()
418
+ except (ImportError, AttributeError) as e:
419
+ # Если импорт не удался, используем стратегию по умолчанию
420
+ from ..chunking.specific_strategies.fixed_size_chunking import \
421
+ FixedSizeChunkingStrategy
422
+
423
+ return FixedSizeChunkingStrategy()
424
+
425
+ # По умолчанию используем стратегию с фиксированным размером
426
+ from ..chunking.specific_strategies.fixed_size_chunking import \
427
+ FixedSizeChunkingStrategy
428
+
429
+ return FixedSizeChunkingStrategy()
lib/extractor/ntr_text_fragmentation/integrations/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль интеграций с внешними хранилищами данных и ORM системами.
3
+ """
4
+
5
+ from .sqlalchemy_repository import SQLAlchemyEntityRepository
6
+
7
+ __all__ = [
8
+ "SQLAlchemyEntityRepository",
9
+ ]
lib/extractor/ntr_text_fragmentation/integrations/sqlalchemy_repository.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Реализация EntityRepository для работы с SQLAlchemy.
3
+ """
4
+
5
+ from abc import abstractmethod
6
+ from typing import Any, Iterable, List, Optional, Type
7
+ from uuid import UUID
8
+
9
+ from sqlalchemy import and_, select
10
+ from sqlalchemy.ext.declarative import declarative_base
11
+ from sqlalchemy.orm import Session
12
+
13
+ from ..core.entity_repository import EntityRepository
14
+ from ..models import Chunk, LinkerEntity
15
+
16
+ Base = declarative_base()
17
+
18
+
19
+ class SQLAlchemyEntityRepository(EntityRepository):
20
+ """
21
+ Реализация EntityRepository для работы с базой данных через SQLAlchemy.
22
+
23
+ Эта реализация предполагает, что таблицы для хранения сущностей уже созданы
24
+ в базе данных и соответствуют определенной структуре.
25
+
26
+ Вы можете наследоваться от этого класса и определить свою структуру моделей,
27
+ переопределив абстрактные методы.
28
+ """
29
+
30
+ def __init__(self, db: Session):
31
+ """
32
+ Инициализирует репозиторий с указанной сессией SQLAlchemy.
33
+
34
+ Args:
35
+ db: Сессия SQLAlchemy для работы с базой данных
36
+ """
37
+ self.db = db
38
+
39
+ @abstractmethod
40
+ def _entity_model_class(self) -> Type['Base']:
41
+ """
42
+ Возвращает класс модели SQLAlchemy для сущностей.
43
+
44
+ Returns:
45
+ Класс модели SQLAlchemy для сущностей
46
+ """
47
+ pass
48
+
49
+ @abstractmethod
50
+ def _map_db_entity_to_linker_entity(self, db_entity: Any) -> LinkerEntity:
51
+ """
52
+ Преобразует сущность из базы данных в LinkerEntity.
53
+
54
+ Args:
55
+ db_entity: Сущность из базы данных
56
+
57
+ Returns:
58
+ Сущность LinkerEntity
59
+ """
60
+ pass
61
+
62
+ def get_entities_by_ids(self, entity_ids: Iterable[UUID]) -> List[LinkerEntity]:
63
+ """
64
+ Получить сущности по списку идентификаторов.
65
+
66
+ Args:
67
+ entity_ids: Список идентификаторов сущностей
68
+
69
+ Returns:
70
+ Список сущностей, соответствующих указанным идентификаторам
71
+ """
72
+ if not entity_ids:
73
+ return []
74
+
75
+ with self.db() as session:
76
+ entity_model = self._entity_model_class()
77
+ db_entities = session.execute(
78
+ select(entity_model).where(entity_model.uuid.in_(list(entity_ids)))
79
+ ).scalars().all()
80
+ print(f"db_entities: {db_entities[:3]}...{db_entities[-3:]}")
81
+
82
+ mapped_entities = [self._map_db_entity_to_linker_entity(entity) for entity in db_entities]
83
+ print(f"mapped_entities: {mapped_entities[:3]}...{mapped_entities[-3:]}")
84
+ return mapped_entities
85
+
86
+ def get_document_for_chunks(self, chunk_ids: Iterable[UUID]) -> List[LinkerEntity]:
87
+ """
88
+ Получить документы, которым принадлежат указанные чанки.
89
+
90
+ Args:
91
+ chunk_ids: Список идентификаторов чанков
92
+
93
+ Returns:
94
+ Список документов, которым принадлежат указанные чанки
95
+ """
96
+ if not chunk_ids:
97
+ return []
98
+
99
+ with self.db() as session:
100
+ entity_model = self._entity_model_class()
101
+
102
+ string_ids = [str(id) for id in chunk_ids]
103
+
104
+ # Получаем все сущности-связи между документами и чанками
105
+ links = session.execute(
106
+ select(entity_model).where(
107
+ and_(
108
+ entity_model.target_id.in_(string_ids),
109
+ entity_model.name == "document_to_chunk",
110
+ entity_model.target_id.isnot(None) # Проверяем, что это связь
111
+ )
112
+ )
113
+ ).scalars().all()
114
+
115
+ if not links:
116
+ return []
117
+
118
+ # Извлекаем ID документов
119
+ doc_ids = [link.source_id for link in links]
120
+
121
+ # Получаем документы по их ID
122
+ documents = session.execute(
123
+ select(entity_model).where(
124
+ and_(
125
+ entity_model.uuid.in_(doc_ids),
126
+ entity_model.entity_type == "DocumentAsEntity"
127
+ )
128
+ )
129
+ ).scalars().all()
130
+
131
+ return [self._map_db_entity_to_linker_entity(doc) for doc in documents]
132
+
133
+ def get_neighboring_chunks(self,
134
+ chunk_ids: Iterable[UUID],
135
+ max_distance: int = 1) -> List[LinkerEntity]:
136
+ """
137
+ Получить соседние чанки для указанных чанков.
138
+
139
+ Args:
140
+ chunk_ids: Список идентификаторов чанков
141
+ max_distance: Максимальное расстояние до соседа
142
+
143
+ Returns:
144
+ Список соседних чанков
145
+ """
146
+ if not chunk_ids:
147
+ return []
148
+
149
+ string_ids = [str(id) for id in chunk_ids]
150
+
151
+ with self.db() as session:
152
+ entity_model = self._entity_model_class()
153
+ result = []
154
+
155
+ # Сначала получаем указанные чанки, чтобы узнать их индексы и документы
156
+ chunks = session.execute(
157
+ select(entity_model).where(
158
+ and_(
159
+ entity_model.uuid.in_(string_ids),
160
+ entity_model.entity_type.like("%Chunk") # Используем LIKE для поиска всех типов чанков
161
+ )
162
+ )
163
+ ).scalars().all()
164
+
165
+ print(f"chunks: {chunks[:3]}...{chunks[-3:]}")
166
+
167
+ if not chunks:
168
+ return []
169
+
170
+ # Находим документы для чанков через связи
171
+ doc_ids = set()
172
+ chunk_indices = {}
173
+
174
+ for chunk in chunks:
175
+ mapped_chunk = self._map_db_entity_to_linker_entity(chunk)
176
+ if not isinstance(mapped_chunk, Chunk):
177
+ continue
178
+
179
+ chunk_indices[chunk.uuid] = mapped_chunk.chunk_index
180
+
181
+ # Находим связь от документа к чанку
182
+ links = session.execute(
183
+ select(entity_model).where(
184
+ and_(
185
+ entity_model.target_id == chunk.uuid,
186
+ entity_model.name == "document_to_chunk"
187
+ )
188
+ )
189
+ ).scalars().all()
190
+
191
+ print(f"links: {links[:3]}...{links[-3:]}")
192
+
193
+ for link in links:
194
+ doc_ids.add(link.source_id)
195
+
196
+ if not doc_ids or not any(idx is not None for idx in chunk_indices.values()):
197
+ return []
198
+
199
+ # Для каждого документа находим все его чанки
200
+ for doc_id in doc_ids:
201
+ # Находим все связи от документа к чанкам
202
+ links = session.execute(
203
+ select(entity_model).where(
204
+ and_(
205
+ entity_model.source_id == doc_id,
206
+ entity_model.name == "document_to_chunk"
207
+ )
208
+ )
209
+ ).scalars().all()
210
+
211
+ doc_chunk_ids = [link.target_id for link in links]
212
+
213
+ print(f"doc_chunk_ids: {doc_chunk_ids[:3]}...{doc_chunk_ids[-3:]}")
214
+
215
+ # Получаем все чанки документа
216
+ doc_chunks = session.execute(
217
+ select(entity_model).where(
218
+ and_(
219
+ entity_model.uuid.in_(doc_chunk_ids),
220
+ entity_model.entity_type.like("%Chunk") # Используем LIKE для поиска всех типов чанков
221
+ )
222
+ )
223
+ ).scalars().all()
224
+
225
+ print(f"doc_chunks: {doc_chunks[:3]}...{doc_chunks[-3:]}")
226
+
227
+ # Для каждого чанка в документе проверяем, является ли он соседом
228
+ for doc_chunk in doc_chunks:
229
+ if doc_chunk.uuid in chunk_ids:
230
+ continue
231
+
232
+ mapped_chunk = self._map_db_entity_to_linker_entity(doc_chunk)
233
+ if not isinstance(mapped_chunk, Chunk):
234
+ continue
235
+
236
+ chunk_index = mapped_chunk.chunk_index
237
+ if chunk_index is None:
238
+ continue
239
+
240
+ # Проверяем, является ли чанк соседом какого-либо из исходных чанков
241
+ is_neighbor = False
242
+ for orig_chunk_id, orig_index in chunk_indices.items():
243
+ if orig_index is not None and abs(chunk_index - orig_index) <= max_distance:
244
+ is_neighbor = True
245
+ break
246
+
247
+ if is_neighbor:
248
+ result.append(mapped_chunk)
249
+
250
+ return result
251
+
252
+ def get_related_entities(self,
253
+ entity_ids: Iterable[UUID],
254
+ relation_name: Optional[str] = None,
255
+ as_source: bool = False,
256
+ as_target: bool = False) -> List[LinkerEntity]:
257
+ """
258
+ Получить сущности, связанные с указанными сущностями.
259
+
260
+ Args:
261
+ entity_ids: Список идентификаторов сущностей
262
+ relation_name: Опциональное имя отношения для фильтрации
263
+ as_source: Если True, ищем связи, где указанные entity_ids являются источниками
264
+ as_target: Если True, ищем связи, где указанные entity_ids являются целями
265
+
266
+ Returns:
267
+ Список связанных сущностей и связей
268
+ """
269
+ if not entity_ids:
270
+ return []
271
+
272
+ entity_model = self._entity_model_class()
273
+ result = []
274
+
275
+ # Если не указано ни as_source, ни as_target, по умолчанию ищем связи,
276
+ # где указанные entity_ids являются источниками
277
+ if not as_source and not as_target:
278
+ as_source = True
279
+
280
+ string_ids = [str(id) for id in entity_ids]
281
+
282
+ with self.db() as session:
283
+ # Поиск связей, где указанные entity_ids являются источниками
284
+ if as_source:
285
+ conditions = [
286
+ entity_model.source_id.in_(string_ids)
287
+ ]
288
+
289
+ if relation_name:
290
+ conditions.append(entity_model.name == relation_name)
291
+
292
+ links = session.execute(
293
+ select(entity_model).where(and_(*conditions))
294
+ ).scalars().all()
295
+
296
+ for link in links:
297
+ # Добавляем связь
298
+ link_entity = self._map_db_entity_to_linker_entity(link)
299
+ result.append(link_entity)
300
+
301
+ # Добавляем целевую сущность
302
+ target_entities = session.execute(
303
+ select(entity_model).where(entity_model.uuid == link.target_id)
304
+ ).scalars().all()
305
+
306
+ for target in target_entities:
307
+ target_entity = self._map_db_entity_to_linker_entity(target)
308
+ if target_entity not in result:
309
+ result.append(target_entity)
310
+
311
+ # Поиск связей, где указанные entity_ids являются целями
312
+ if as_target:
313
+ conditions = [
314
+ entity_model.target_id.in_(string_ids)
315
+ ]
316
+
317
+ if relation_name:
318
+ conditions.append(entity_model.name == relation_name)
319
+
320
+ links = session.execute(
321
+ select(entity_model).where(and_(*conditions))
322
+ ).scalars().all()
323
+
324
+ for link in links:
325
+ # Добавляем связь
326
+ link_entity = self._map_db_entity_to_linker_entity(link)
327
+ result.append(link_entity)
328
+
329
+ # Добавляем исходную сущность
330
+ source_entities = session.execute(
331
+ select(entity_model).where(entity_model.uuid == link.source_id)
332
+ ).scalars().all()
333
+
334
+ for source in source_entities:
335
+ source_entity = self._map_db_entity_to_linker_entity(source)
336
+ if source_entity not in result:
337
+ result.append(source_entity)
338
+
339
+ return result
lib/extractor/ntr_text_fragmentation/models/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Модуль моделей данных.
3
+ """
4
+
5
+ from .chunk import Chunk
6
+ from .document import DocumentAsEntity
7
+ from .linker_entity import LinkerEntity
8
+
9
+ __all__ = [
10
+ "LinkerEntity",
11
+ "DocumentAsEntity",
12
+ "Chunk",
13
+ ]
lib/extractor/ntr_text_fragmentation/models/chunk.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Класс для представления чанка документа.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .linker_entity import LinkerEntity, register_entity
8
+
9
+
10
+ @register_entity
11
+ @dataclass
12
+ class Chunk(LinkerEntity):
13
+ """
14
+ Класс для представления чанка документа в системе извлечения и сборки.
15
+
16
+ Attributes:
17
+ chunk_index: Порядковый номер чанка в документе (0-based).
18
+ Используется для восстановления порядка при сборке.
19
+ """
20
+
21
+ chunk_index: int | None = None
22
+
23
+ @classmethod
24
+ def deserialize(cls, data: LinkerEntity) -> 'Chunk':
25
+ """
26
+ Десериализует Chunk из объекта LinkerEntity.
27
+
28
+ Базовый класс Chunk не должен использоваться напрямую,
29
+ все конкретные реализации должны переопределить этот метод.
30
+
31
+ Args:
32
+ data: Объект LinkerEntity для преобразования в Chunk
33
+
34
+ Raises:
35
+ NotImplementedError: Метод должен быть переопределен в дочерних классах
36
+ """
37
+ if cls == Chunk:
38
+ # Если это прямой вызов на базовом классе Chunk, выбрасываем исключение
39
+ raise NotImplementedError(
40
+ "Базовый класс Chunk не поддерживает десериализацию. "
41
+ "Используйте конкретную реализацию Chunk (например, FixedSizeChunk)."
42
+ )
43
+
44
+ # Если вызывается из дочернего класса, который не переопределил метод,
45
+ # выбрасываем более конкретную ошибку
46
+ raise NotImplementedError(
47
+ f"Класс {cls.__name__} должен реализовать метод deserialize."
48
+ )
lib/extractor/ntr_text_fragmentation/models/document.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Класс для представления документа как сущности.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .linker_entity import LinkerEntity, register_entity
8
+
9
+
10
+ @register_entity
11
+ @dataclass
12
+ class DocumentAsEntity(LinkerEntity):
13
+ """
14
+ Класс для представления документа как сущности в системе извлечения и сборки.
15
+ """
16
+
17
+ doc_type: str = "unknown"
18
+
19
+ @classmethod
20
+ def deserialize(cls, data: LinkerEntity) -> 'DocumentAsEntity':
21
+ """
22
+ Десериализует DocumentAsEntity из объекта LinkerEntity.
23
+
24
+ Args:
25
+ data: Объект LinkerEntity для преобразования в DocumentAsEntity
26
+
27
+ Returns:
28
+ Десериализованный объект DocumentAsEntity
29
+ """
30
+ metadata = data.metadata or {}
31
+
32
+ # Получаем тип документа из метаданных или используем значение по умолчанию
33
+ doc_type = metadata.get('_doc_type', 'unknown')
34
+
35
+ # Создаем чистые метаданные без служебных полей
36
+ clean_metadata = {k: v for k, v in metadata.items() if not k.startswith('_')}
37
+
38
+ return cls(
39
+ id=data.id,
40
+ name=data.name,
41
+ text=data.text,
42
+ in_search_text=data.in_search_text,
43
+ metadata=clean_metadata,
44
+ source_id=data.source_id,
45
+ target_id=data.target_id,
46
+ number_in_relation=data.number_in_relation,
47
+ type="DocumentAsEntity",
48
+ doc_type=doc_type
49
+ )
lib/extractor/ntr_text_fragmentation/models/linker_entity.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Базовый абстрактный класс для всех сущностей с поддержкой триплетного подхода.
3
+ """
4
+
5
+ import uuid
6
+ from abc import abstractmethod
7
+ from dataclasses import dataclass, field, fields
8
+ from uuid import UUID
9
+
10
+
11
+ @dataclass
12
+ class LinkerEntity:
13
+ """
14
+ Общий класс для всех сущностей в системе извлечения и сборки.
15
+ Поддерживает триплетный подход, где каждая сущность может опционально связывать две другие сущности.
16
+
17
+ Attributes:
18
+ id (UUID): Уникальный идентификатор сущности.
19
+ name (str): Название сущности.
20
+ text (str): Текстое представление сущности.
21
+ in_search_text (str | None): Текст для поиска. Если задан, используется в __str__, иначе используется обычное представление.
22
+ metadata (dict): Метаданные сущности.
23
+ source_id (UUID | None): Опциональный идентификатор исходной сущности.
24
+ Если указан, эта сущность является связью.
25
+ target_id (UUID | None): Опциональный идентификатор целевой сущности.
26
+ Если указан, эта сущность является связью.
27
+ number_in_relation (int | None): Используется в случае связей один-ко-многим,
28
+ указывает номер целевой сущности в списке.
29
+ type (str): Тип сущности.
30
+ """
31
+
32
+ id: UUID
33
+ name: str
34
+ text: str
35
+ metadata: dict # JSON с метаданными
36
+ in_search_text: str | None = None
37
+ source_id: UUID | None = None
38
+ target_id: UUID | None = None
39
+ number_in_relation: int | None = None
40
+ type: str = field(default_factory=lambda: "Entity")
41
+
42
+ def __post_init__(self):
43
+ if self.id is None:
44
+ self.id = uuid.uuid4()
45
+
46
+ # Проверяем корректность полей связи
47
+ if (self.source_id is not None and self.target_id is None) or \
48
+ (self.source_id is None and self.target_id is not None):
49
+ raise ValueError("source_id и target_id должны быть либо оба указаны, либо оба None")
50
+
51
+ def is_link(self) -> bool:
52
+ """
53
+ Проверяет, является ли сущность связью (имеет и source_id, и target_id).
54
+
55
+ Returns:
56
+ bool: True, если сущность является связью, иначе False
57
+ """
58
+ return self.source_id is not None and self.target_id is not None
59
+
60
+ def __str__(self) -> str:
61
+ """
62
+ Возвращает строковое представление сущности.
63
+ Если задан in_search_text, возвращает его, иначе возвращает стандартное представление.
64
+ """
65
+ if self.in_search_text is not None:
66
+ return self.in_search_text
67
+ return f"{self.name}: {self.text}"
68
+
69
+ def __eq__(self, other: 'LinkerEntity') -> bool:
70
+ """
71
+ Сравнивает текущую сущность с другой.
72
+
73
+ Args:
74
+ other: Другая сущность для сравнения
75
+
76
+ Returns:
77
+ bool: True если сущности совпадают, иначе False
78
+ """
79
+ if not isinstance(other, self.__class__):
80
+ return False
81
+
82
+ basic_equality = (
83
+ self.id == other.id
84
+ and self.name == other.name
85
+ and self.text == other.text
86
+ and self.type == other.type
87
+ )
88
+
89
+ # Если мы имеем дело со связями, также проверяем поля связи
90
+ if self.is_link() or other.is_link():
91
+ return (
92
+ basic_equality
93
+ and self.source_id == other.source_id
94
+ and self.target_id == other.target_id
95
+ )
96
+
97
+ return basic_equality
98
+
99
+ def serialize(self) -> 'LinkerEntity':
100
+ """
101
+ Сериализует сущность в простейшую форму сущности, передавая все дополнительные поля в метаданные.
102
+ """
103
+ # Получаем список полей базового класса
104
+ known_fields = {field.name for field in fields(LinkerEntity)}
105
+
106
+ # Получаем все атрибуты текущего объекта
107
+ dict_entity = {}
108
+ for attr_name in dir(self):
109
+ # Пропускаем служебные атрибуты, методы и уже известные поля
110
+ if (
111
+ attr_name.startswith('_')
112
+ or attr_name in known_fields
113
+ or callable(getattr(self, attr_name))
114
+ ):
115
+ continue
116
+
117
+ # Добавляем дополнительные поля в словарь
118
+ dict_entity[attr_name] = getattr(self, attr_name)
119
+
120
+ # Преобразуем имена дополнительных полей, добавляя префикс "_"
121
+ dict_entity = {f'_{name}': value for name, value in dict_entity.items()}
122
+
123
+ # Объединяем с существующими метаданными
124
+ dict_entity = {**dict_entity, **self.metadata}
125
+
126
+ result_type = self.type
127
+ if result_type == "Entity":
128
+ result_type = self.__class__.__name__
129
+
130
+ # Создаем базовый объект LinkerEntity с новыми метаданными
131
+ return LinkerEntity(
132
+ id=self.id,
133
+ name=self.name,
134
+ text=self.text,
135
+ in_search_text=self.in_search_text,
136
+ metadata=dict_entity,
137
+ source_id=self.source_id,
138
+ target_id=self.target_id,
139
+ number_in_relation=self.number_in_relation,
140
+ type=result_type,
141
+ )
142
+
143
+ @classmethod
144
+ @abstractmethod
145
+ def deserialize(cls, data: 'LinkerEntity') -> 'Self':
146
+ """
147
+ Десериализует сущность из простейшей формы сущности, учитывая все дополнительные поля в метаданных.
148
+ """
149
+ raise NotImplementedError(
150
+ f"Метод deserialize для класса {cls.__class__.__name__} не реализован"
151
+ )
152
+
153
+ # Реестр для хранения всех наследников LinkerEntity
154
+ _entity_classes = {}
155
+
156
+ @classmethod
157
+ def register_entity_class(cls, entity_class):
158
+ """
159
+ Регистрирует класс-наследник в реестре.
160
+
161
+ Args:
162
+ entity_class: Класс для регистрации
163
+ """
164
+ entity_type = entity_class.__name__
165
+ cls._entity_classes[entity_type] = entity_class
166
+ # Также регистрируем по типу, если он отличается от имени класса
167
+ if hasattr(entity_class, 'type') and isinstance(entity_class.type, str):
168
+ cls._entity_classes[entity_class.type] = entity_class
169
+
170
+ @classmethod
171
+ def deserialize(cls, data: 'LinkerEntity') -> 'LinkerEntity':
172
+ """
173
+ Десериализует сущность в нужный тип на основе поля type.
174
+
175
+ Args:
176
+ data: Сериализованная сущность типа LinkerEntity
177
+
178
+ Returns:
179
+ Десериализованная сущность правильного типа
180
+ """
181
+ # Получаем тип сущности
182
+ entity_type = data.type
183
+
184
+ # Проверяем реестр классов
185
+ if entity_type in cls._entity_classes:
186
+ try:
187
+ return cls._entity_classes[entity_type].deserialize(data)
188
+ except (AttributeError, NotImplementedError) as e:
189
+ # Если метод не реализован, возвращаем исходную сущность
190
+ return data
191
+
192
+ # Если тип не найден в реестре, просто возвращаем исходную сущность
193
+ # Больше не используем опасное сканирование sys.modules
194
+ return data
195
+
196
+
197
+ # Декоратор для регистрации производных классов
198
+ def register_entity(cls):
199
+ """
200
+ Декоратор для регистрации классов-наследников LinkerEntity.
201
+
202
+ Пример использования:
203
+
204
+ @register_entity
205
+ class MyEntity(LinkerEntity):
206
+ type = "my_entity"
207
+
208
+ Args:
209
+ cls: Класс, который нужно зарегистрировать
210
+
211
+ Returns:
212
+ Исходный класс (без изменений)
213
+ """
214
+ # Регистрируем класс в реестр, используя его имя или указанный тип
215
+ entity_type = getattr(cls, 'type', cls.__name__)
216
+ LinkerEntity._entity_classes[entity_type] = cls
217
+ return cls
lib/extractor/pyproject.toml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = ["setuptools>=61"]
4
+
5
+ [project]
6
+ name = "ntr_text_fragmentation"
7
+ version = "0.1.0"
8
+ dependencies = [
9
+ "uuid==1.30",
10
+ "ntr_fileparser @ git+ssh://[email protected]/textai/parsers/parser.git@master"
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ test = [
15
+ "pytest>=7.0.0",
16
+ "pytest-cov>=4.0.0"
17
+ ]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+
22
+ [tool.pytest]
23
+ testpaths = ["tests"]
24
+ python_files = "test_*.py"
25
+ python_classes = "Test*"
26
+ python_functions = "test_*"
lib/extractor/scripts/README_test_chunking.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Тестирование чанкинга и сборки документов
2
+
3
+ Скрипт `test_chunking.py` позволяет тестировать различные стратегии чанкинга документов и их последующую сборку.
4
+
5
+ ## Возможности
6
+
7
+ 1. **Разбивка документов** - применение различных стратегий чанкинга к документам
8
+ 2. **Сохранение результатов** - сохранение чанков и метаданных в CSV
9
+ 3. **Сборка документов** - загрузка чанков из CSV и сборка документа с помощью InjectionBuilder
10
+ 4. **Фильтрация чанков** - возможность фильтровать чанки по индексу или ключевым словам
11
+
12
+ ## Режимы работы
13
+
14
+ Скрипт поддерживает три режима работы:
15
+
16
+ 1. **chunk** - только разбивка документа на чанки и сохранение в CSV
17
+ 2. **build** - загрузка чанков из CSV и сборка документа
18
+ 3. **full** - разбивка документа, сохранение в CSV и последующая сборка
19
+
20
+ ## Примеры использования
21
+
22
+ ### Разбивка документа на чанки (стратегия fixed_size)
23
+
24
+ ```bash
25
+ python scripts/test_chunking.py --mode chunk --input test_input/test.docx --strategy fixed_size --words 50 --overlap 25
26
+ ```
27
+
28
+ ### Разбивка документа на чанки (стратегия sentence)
29
+
30
+ ```bash
31
+ python scripts/test_chunking.py --mode chunk --input test_input/test.docx --strategy sentence
32
+ ```
33
+
34
+ ### Загрузка чанков из CSV и сборка документа (все чанки)
35
+
36
+ ```bash
37
+ python scripts/test_chunking.py --mode build --csv test_output/test_fixed_size_w50_o25.csv
38
+ ```
39
+
40
+ ### Загрузка чанков из CSV и сборка документа (с фильтрацией по индексу)
41
+
42
+ ```bash
43
+ python scripts/test_chunking.py --mode build --csv test_output/test_fixed_size_w50_o25.csv --filter index --filter-value "0,2,4"
44
+ ```
45
+
46
+ ### Загрузка чанков из CSV и сборка документа (с фильтрацией по ключевому слову)
47
+
48
+ ```bash
49
+ python scripts/test_chunking.py --mode build --csv test_output/test_fixed_size_w50_o25.csv --filter keyword --filter-value "важно"
50
+ ```
51
+
52
+ ### Полный цикл: разбивка, сохранение и сборка
53
+
54
+ ```bash
55
+ python scripts/test_chunking.py --mode full --input test_input/test.docx --strategy fixed_size --words 50 --overlap 25
56
+ ```
57
+
58
+ ## Параметры командной строки
59
+
60
+ ### Основные параметры
61
+
62
+ | Параметр | Описание | Значения по умолчанию |
63
+ |----------|----------|------------------------|
64
+ | `--mode` | Режим работы | `chunk` |
65
+ | `--input` | Путь к входному файлу | `test_input/test.docx` |
66
+ | `--csv` | Путь к CSV файлу с сущностями | None |
67
+ | `--output-dir` | Директория для выходных файлов | `test_output` |
68
+
69
+ ### Параметры стратегии чанкинга
70
+
71
+ | Параметр | Описание | Значения по умолчанию |
72
+ |----------|----------|------------------------|
73
+ | `--strategy` | Стратегия чанкинга | `fixed_size` |
74
+ | `--words` | Количество слов в чанке (для fixed_size) | 50 |
75
+ | `--overlap` | Перекрытие в словах (для fixed_size) | 25 |
76
+ | `--debug` | Режим отладки (для numbered_items) | False |
77
+
78
+ ### Параметры фильтрации
79
+
80
+ | Параметр | Описание | Значения по умолчанию |
81
+ |----------|----------|------------------------|
82
+ | `--filter` | Тип фильтрации чанков | `none` |
83
+ | `--filter-value` | Значение для фильтрации | None |
84
+
85
+ ## Подготовка тестовых данных
86
+
87
+ Для тестирования скрипта вам понадобится документ в формате docx, txt, pdf или другом поддерживаемом формате. Поместите тестовый документ в папку `test_input`.
88
+
89
+ ## Результаты работы
90
+
91
+ После выполнения скрипта в папке `test_output` будут созданы следующие файлы:
92
+
93
+ 1. **test_{strategy}_....csv** - CSV файл с сущностями (документ, чанки, связи)
94
+ 2. **rebuilt_document_{filter}_{filter_value}.txt** - собранный текст документа (при использовании режимов build или full)
95
+
96
+ ## Примечания
97
+
98
+ - Для различных стратегий чанкинга доступны разные пара��етры
99
+ - При сборке документа можно использовать фильтрацию чанков по индексу или ключевому слову
100
+ - Собранный документ будет отличаться от исходного, если использовалась фильтрация чанков
101
+
102
+ ## Требования
103
+
104
+ - Python 3.8+
105
+ - pandas
106
+ - ntr_fileparser
107
+ - ntr_text_fragmentation
lib/extractor/scripts/analyze_missing_puncts.py ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для анализа ненайденных пунктов по лучшему подходу чанкинга (200 слов, 75 перекрытие, baai/bge-m3, top-100).
4
+ Формирует отчет в формате Markdown с топ-5 наиболее похожими чанками для каждого ненайденного пункта.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from fuzzywuzzy import fuzz
16
+ from sklearn.metrics.pairwise import cosine_similarity
17
+ from tqdm import tqdm
18
+
19
+ # Константы
20
+ DATA_FOLDER = "data/docs" # Путь к папке с документами
21
+ MODEL_NAME = "BAAI/bge-m3" # Название лучшей модели
22
+ DATASET_PATH = "data/dataset.xlsx" # Путь к Excel-датасету с вопросами
23
+ OUTPUT_DIR = "data" # Директория для сохранения результатов
24
+ MARKDOWN_FILE = "missing_puncts_analysis.md" # Имя выходного MD-файла
25
+ SIMILARITY_THRESHOLD = 0.7 # Порог для нечеткого сравнения
26
+ WORDS_PER_CHUNK = 200 # Размер чанка в словах
27
+ OVERLAP_WORDS = 75 # Перекрытие в словах
28
+ TOP_N = 100 # Количество чанков в топе
29
+
30
+ sys.path.insert(0, str(Path(__file__).parent.parent))
31
+
32
+
33
+ def parse_args():
34
+ """
35
+ Парсит аргументы командной строки.
36
+
37
+ Returns:
38
+ Аргументы командной строки
39
+ """
40
+ parser = argparse.ArgumentParser(description="Анализ ненайденных пунктов для лучшего подхода чанкинга")
41
+
42
+ parser.add_argument("--data-folder", type=str, default=DATA_FOLDER,
43
+ help=f"Путь к папке с документами (по умолчанию: {DATA_FOLDER})")
44
+ parser.add_argument("--model-name", type=str, default=MODEL_NAME,
45
+ help=f"Название модели (по умолчанию: {MODEL_NAME})")
46
+ parser.add_argument("--dataset-path", type=str, default=DATASET_PATH,
47
+ help=f"Путь к Excel-датасету с вопросами (по умолчанию: {DATASET_PATH})")
48
+ parser.add_argument("--output-dir", type=str, default=OUTPUT_DIR,
49
+ help=f"Директория для сохранения результатов (по умолчанию: {OUTPUT_DIR})")
50
+ parser.add_argument("--markdown-file", type=str, default=MARKDOWN_FILE,
51
+ help=f"Имя выходного MD-файла (по умолчанию: {MARKDOWN_FILE})")
52
+ parser.add_argument("--similarity-threshold", type=float, default=SIMILARITY_THRESHOLD,
53
+ help=f"Порог для нечеткого сравнения (по умолчанию: {SIMILARITY_THRESHOLD})")
54
+ parser.add_argument("--words-per-chunk", type=int, default=WORDS_PER_CHUNK,
55
+ help=f"Размер чанка в словах (по умолчанию: {WORDS_PER_CHUNK})")
56
+ parser.add_argument("--overlap-words", type=int, default=OVERLAP_WORDS,
57
+ help=f"Перекрытие в словах (по умолчанию: {OVERLAP_WORDS})")
58
+ parser.add_argument("--top-n", type=int, default=TOP_N,
59
+ help=f"Количество чанков в топе (по умолчанию: {TOP_N})")
60
+
61
+ return parser.parse_args()
62
+
63
+
64
+ def load_questions_dataset(file_path: str) -> pd.DataFrame:
65
+ """
66
+ Загружает датасет с вопросами из Excel-файла.
67
+
68
+ Args:
69
+ file_path: Путь к Excel-файлу
70
+
71
+ Returns:
72
+ DataFrame с вопросами и пунктами
73
+ """
74
+ print(f"Загрузка датасета из {file_path}...")
75
+
76
+ df = pd.read_excel(file_path)
77
+ print(f"Загружен датасет со столбцами: {df.columns.tolist()}")
78
+
79
+ # Преобразуем NaN в пустые строки для текстовых полей
80
+ text_columns = ['question', 'text', 'item_type']
81
+ for col in text_columns:
82
+ if col in df.columns:
83
+ df[col] = df[col].fillna('')
84
+
85
+ return df
86
+
87
+
88
+ def load_chunks_and_embeddings(output_dir: str, words_per_chunk: int, overlap_words: int, model_name: str) -> tuple:
89
+ """
90
+ Загружает чанки и эмбеддинги из файлов.
91
+
92
+ Args:
93
+ output_dir: Директория с файлами
94
+ words_per_chunk: Размер чанка в словах
95
+ overlap_words: Перекрытие в словах
96
+ model_name: Название модели
97
+
98
+ Returns:
99
+ Кортеж (чанки, эмбе��динги чанков, эмбеддинги вопросов, данные вопросов)
100
+ """
101
+ # Формируем уникальное имя для файлов на основе параметров
102
+ model_name_safe = model_name.replace('/', '_')
103
+ strategy_config_str = f"fixed_size_w{words_per_chunk}_o{overlap_words}"
104
+ chunks_filename = f"chunks_{strategy_config_str}_{model_name_safe}"
105
+ questions_filename = f"questions_{model_name_safe}"
106
+
107
+ # Пути к файлам
108
+ chunks_embeddings_path = os.path.join(output_dir, f"{chunks_filename}_embeddings.npy")
109
+ chunks_data_path = os.path.join(output_dir, f"{chunks_filename}_data.csv")
110
+ questions_embeddings_path = os.path.join(output_dir, f"{questions_filename}_embeddings.npy")
111
+ questions_data_path = os.path.join(output_dir, f"{questions_filename}_data.csv")
112
+
113
+ # Проверяем наличие всех файлов
114
+ for path in [chunks_embeddings_path, chunks_data_path, questions_embeddings_path, questions_data_path]:
115
+ if not os.path.exists(path):
116
+ raise FileNotFoundError(f"Файл {path} не найден")
117
+
118
+ # Загружаем данные
119
+ print(f"Загрузка данных из {output_dir}...")
120
+ chunks_embeddings = np.load(chunks_embeddings_path)
121
+ chunks_df = pd.read_csv(chunks_data_path)
122
+ questions_embeddings = np.load(questions_embeddings_path)
123
+ questions_df = pd.read_csv(questions_data_path)
124
+
125
+ print(f"Загружено {len(chunks_df)} чанков и {len(questions_df)} вопросов")
126
+
127
+ return chunks_df, chunks_embeddings, questions_embeddings, questions_df
128
+
129
+
130
+ def load_top_chunks(top_chunks_dir: str) -> dict:
131
+ """
132
+ Загружает JSON-файлы с топ-чанками для вопросов.
133
+
134
+ Args:
135
+ top_chunks_dir: Директория с JSON-файлами
136
+
137
+ Returns:
138
+ Словарь {question_id: данные из JSON}
139
+ """
140
+ print(f"Загрузка топ-чанков из {top_chunks_dir}...")
141
+
142
+ top_chunks_data = {}
143
+ json_files = list(Path(top_chunks_dir).glob("question_*_top_chunks.json"))
144
+
145
+ for json_file in tqdm(json_files, desc="Загрузка JSON-файлов"):
146
+ try:
147
+ with open(json_file, 'r', encoding='utf-8') as f:
148
+ data = json.load(f)
149
+ question_id = data.get('question_id')
150
+ if question_id is not None:
151
+ top_chunks_data[question_id] = data
152
+ except Exception as e:
153
+ print(f"Ошибка при загрузке файла {json_file}: {e}")
154
+
155
+ print(f"Загружены данные для {len(top_chunks_data)} вопросов")
156
+
157
+ return top_chunks_data
158
+
159
+
160
+ def calculate_chunk_overlap(chunk_text: str, punct_text: str) -> float:
161
+ """
162
+ Рассчитывает степень перекрытия между чанком и пунктом с использованием partial_ratio.
163
+
164
+ Args:
165
+ chunk_text: Текст чанка
166
+ punct_text: Текст пункта
167
+
168
+ Returns:
169
+ Коэффициент перекрытия от 0 до 1
170
+ """
171
+ # Если чанк входит в пункт, возвращаем 1.0 (полное вхождение)
172
+ if chunk_text in punct_text:
173
+ return 1.0
174
+
175
+ # Если пункт входит в чанк, возвращаем соотношение длин
176
+ if punct_text in chunk_text:
177
+ return len(punct_text) / len(chunk_text)
178
+
179
+ # Используем partial_ratio из fuzzywuzzy
180
+ partial_ratio_score = fuzz.partial_ratio(chunk_text, punct_text) / 100.0
181
+
182
+ return partial_ratio_score
183
+
184
+
185
+ def find_most_similar_chunks(punct_text: str, chunks_df: pd.DataFrame, chunks_embeddings: np.ndarray, punct_embedding: np.ndarray, top_n: int = 5) -> list:
186
+ """
187
+ Находит топ-N наиболее похожих чанков для заданного пункта.
188
+
189
+ Args:
190
+ punct_text: Текст пункта
191
+ chunks_df: DataFrame с чанками
192
+ chunks_embeddings: Эмбеддинги чанков
193
+ punct_embedding: Эмбеддинг пункта
194
+ top_n: Количество похожих чанков (по умолчанию 5)
195
+
196
+ Returns:
197
+ Список словарей с информацией о похожих чанках
198
+ """
199
+ # Вычисляем косинусную близость между пунктом и всеми чанками
200
+ similarities = cosine_similarity([punct_embedding], chunks_embeddings)[0]
201
+
202
+ # Получаем индексы топ-N чанков по косинусной близости
203
+ top_indices = np.argsort(similarities)[-top_n:][::-1]
204
+
205
+ similar_chunks = []
206
+ for idx in top_indices:
207
+ chunk = chunks_df.iloc[idx]
208
+ overlap = calculate_chunk_overlap(chunk['text'], punct_text)
209
+
210
+ similar_chunks.append({
211
+ 'chunk_id': chunk['id'],
212
+ 'doc_name': chunk['doc_name'],
213
+ 'text': chunk['text'],
214
+ 'similarity': float(similarities[idx]),
215
+ 'overlap': overlap
216
+ })
217
+
218
+ return similar_chunks
219
+
220
+
221
+ def analyze_missing_puncts(questions_df: pd.DataFrame, chunks_df: pd.DataFrame,
222
+ questions_embeddings: np.ndarray, chunks_embeddings: np.ndarray,
223
+ similarity_threshold: float, top_n: int = 100) -> dict:
224
+ """
225
+ Анализирует ненайденные пункты и находит для них наиболее похожие чанки.
226
+
227
+ Args:
228
+ questions_df: DataFrame с вопросами и пунктами
229
+ chunks_df: DataFrame с чанками
230
+ questions_embeddings: Эмбеддинги вопросов
231
+ chunks_embeddings: Эмбеддинги чанков
232
+ similarity_threshold: Порог для определения найденных пунктов
233
+ top_n: Количество чанков для проверки (по умолчанию 100)
234
+
235
+ Returns:
236
+ Словарь с результатами анализа
237
+ """
238
+ print("Анализ ненайденных пунктов...")
239
+
240
+ # Проверяем соответствие количества вопросов и эмбеддингов
241
+ unique_question_ids = questions_df['id'].unique()
242
+ if len(unique_question_ids) != questions_embeddings.shape[0]:
243
+ print(f"ВНИМАНИЕ: Количество уникальных ID вопросов ({len(unique_question_ids)}) не соответствует размеру массива эмбеддингов ({questions_embeddings.shape[0]}).")
244
+ print("Будут анализироваться только вопросы, имеющие соответствующие эмбеддинги.")
245
+
246
+ # Создаем маппинг id вопроса -> индекс в DataFrame с метаданными
247
+ # Используем порядковый номер в списке уникальных ID, а не порядок строк в DataFrame
248
+ question_id_to_idx = {qid: idx for idx, qid in enumerate(unique_question_ids)}
249
+
250
+ # Вычисляем косинусную близость между вопросами и чанками
251
+ similarity_matrix = cosine_similarity(questions_embeddings, chunks_embeddings)
252
+
253
+ # Результаты анализа
254
+ analysis_results = {}
255
+
256
+ # Обрабатываем только те вопросы, для которых у нас есть эмбеддинги
257
+ valid_question_ids = [qid for qid in unique_question_ids if qid in question_id_to_idx and question_id_to_idx[qid] < len(questions_embeddings)]
258
+
259
+ # Группируем датасет по id вопроса
260
+ for question_id in tqdm(valid_question_ids, desc="Анализ вопросов"):
261
+ # Получаем строки для текущего вопроса
262
+ question_rows = questions_df[questions_df['id'] == question_id]
263
+
264
+ # Если нет строк с таким id, пропускаем
265
+ if len(question_rows) == 0:
266
+ continue
267
+
268
+ # Получаем индекс вопроса в массиве эмбеддингов
269
+ question_idx = question_id_to_idx[question_id]
270
+
271
+ # Если индекс выходит за границы массива эмбеддингов, пропускаем
272
+ if question_idx >= questions_embeddings.shape[0]:
273
+ print(f"ВНИМАНИЕ: Индекс {question_idx} для вопроса {question_id} выходит за границы массива эмбеддингов размера {questions_embeddings.shape[0]}. Пропускаем.")
274
+ continue
275
+
276
+ # Получаем текст вопроса и пункты
277
+ question_text = question_rows['question'].iloc[0]
278
+
279
+ # Собираем пункты с информацией о документе
280
+ puncts = []
281
+ for _, row in question_rows.iterrows():
282
+ punct_doc = row.get('filename', '') if 'filename' in row else ''
283
+ if pd.isna(punct_doc):
284
+ punct_doc = ''
285
+ puncts.append({
286
+ 'text': row['text'],
287
+ 'doc_name': punct_doc
288
+ })
289
+
290
+ # Получаем связанные документы
291
+ relevant_docs = []
292
+ if 'filename' in question_rows.columns:
293
+ relevant_docs = [f for f in question_rows['filename'].unique() if f and not pd.isna(f)]
294
+ else:
295
+ relevant_docs = chunks_df['doc_name'].unique().tolist()
296
+
297
+ # Если для вопроса нет релевантных документов, пропускаем
298
+ if not relevant_docs:
299
+ continue
300
+
301
+ # Для отслеживания найденных и ненайденных пунктов
302
+ found_puncts = []
303
+ missing_puncts = []
304
+
305
+ # Собираем все чанки для документов вопроса
306
+ all_question_chunks = []
307
+ all_question_similarities = []
308
+
309
+ for filename in relevant_docs:
310
+ if not filename or pd.isna(filename):
311
+ continue
312
+
313
+ # Фильтруем чанки по имени файла
314
+ doc_chunks = chunks_df[chunks_df['doc_name'] == filename]
315
+
316
+ if doc_chunks.empty:
317
+ continue
318
+
319
+ # Индексы чанков для текущего файла
320
+ doc_chunk_indices = doc_chunks.index.tolist()
321
+
322
+ # Проверяем, что индексы чанков существуют в chunks_df
323
+ valid_indices = [idx for idx in doc_chunk_indices if idx in chunks_df.index]
324
+
325
+ # Получаем значения близости для чанков текущего файла
326
+ doc_similarities = []
327
+ for idx in valid_indices:
328
+ try:
329
+ chunk_loc = chunks_df.index.get_loc(idx)
330
+ doc_similarities.append(similarity_matrix[question_idx, chunk_loc])
331
+ except (KeyError, IndexError) as e:
332
+ print(f"Ошибка при получении индекса для чанка {idx}: {e}")
333
+ continue
334
+
335
+ # Добавляем чанки и их схожести к общему списку для вопроса
336
+ for i, idx in enumerate(valid_indices):
337
+ if i < len(doc_similarities): # проверяем, что у нас есть соответствующее значение similarity
338
+ try:
339
+ chunk_row = doc_chunks.loc[idx]
340
+ all_question_chunks.append((idx, chunk_row))
341
+ all_question_similarities.append(doc_similarities[i])
342
+ except KeyError as e:
343
+ print(f"Ошибка при доступе к строке с индексом {idx}: {e}")
344
+
345
+ # Если нет чанков для вопроса, пропускаем
346
+ if not all_question_chunks:
347
+ continue
348
+
349
+ # Сортируем все чанки по убыванию схожести и берем top_n
350
+ sorted_indices = np.argsort(all_question_similarities)[-min(top_n, len(all_question_similarities)):][::-1]
351
+ top_chunks = []
352
+ top_similarities = []
353
+
354
+ # Собираем топ-N чанков и их схожести
355
+ for i in sorted_indices:
356
+ idx, chunk = all_question_chunks[i]
357
+ top_chunks.append({
358
+ 'id': chunk['id'],
359
+ 'doc_name': chunk['doc_name'],
360
+ 'text': chunk['text']
361
+ })
362
+ top_similarities.append(all_question_similarities[i])
363
+
364
+ # Проверяем каждый пункт на наличие в топ-чанках
365
+ for i, punct in enumerate(puncts):
366
+ is_found = False
367
+ punct_text = punct['text']
368
+ punct_doc = punct['doc_name']
369
+
370
+ # Для каждого чанка из топ-N рассчитываем partial_ratio с пунктом
371
+ chunk_overlaps = []
372
+ for j, chunk in enumerate(top_chunks):
373
+ overlap = calculate_chunk_overlap(chunk['text'], punct_text)
374
+
375
+ # Если перекрытие больше порога, пункт найден
376
+ if overlap >= similarity_threshold:
377
+ is_found = True
378
+
379
+ # Сохраняем информацию о перекрытии для каждого чанка
380
+ chunk_overlaps.append({
381
+ 'chunk_id': chunk['id'],
382
+ 'doc_name': chunk['doc_name'],
383
+ 'text': chunk['text'],
384
+ 'overlap': overlap,
385
+ 'similarity': float(top_similarities[j])
386
+ })
387
+
388
+ # Если пункт найден, добавляем в список найденных
389
+ if is_found:
390
+ found_puncts.append({
391
+ 'index': i,
392
+ 'text': punct_text,
393
+ 'doc_name': punct_doc
394
+ })
395
+ else:
396
+ # Сортируем чанки по убыванию перекрытия с пунктом и берем топ-5
397
+ chunk_overlaps.sort(key=lambda x: x['overlap'], reverse=True)
398
+ top_overlaps = chunk_overlaps[:5]
399
+
400
+ missing_puncts.append({
401
+ 'index': i,
402
+ 'text': punct_text,
403
+ 'doc_name': punct_doc,
404
+ 'similar_chunks': top_overlaps
405
+ })
406
+
407
+ # Добавляем результаты для текущего вопроса
408
+ analysis_results[question_id] = {
409
+ 'question_id': question_id,
410
+ 'question_text': question_text,
411
+ 'found_puncts_count': len(found_puncts),
412
+ 'missing_puncts_count': len(missing_puncts),
413
+ 'total_puncts_count': len(puncts),
414
+ 'found_puncts': found_puncts,
415
+ 'missing_puncts': missing_puncts
416
+ }
417
+
418
+ return analysis_results
419
+
420
+
421
+ def generate_markdown_report(analysis_results: dict, output_file: str,
422
+ words_per_chunk: int, overlap_words: int, model_name: str, top_n: int):
423
+ """
424
+ Генерирует отчет в формате Markdown.
425
+
426
+ Args:
427
+ analysis_results: Результаты анализа
428
+ output_file: Путь к выходному файлу
429
+ words_per_chunk: Размер чанка в словах
430
+ overlap_words: Перекрытие в словах
431
+ model_name: Название модели
432
+ top_n: Количество чанков в топе
433
+ """
434
+ print(f"Генерация отчета в формате Markdown в {output_file}...")
435
+
436
+ with open(output_file, 'w', encoding='utf-8') as f:
437
+ # Заголовок отчета
438
+ f.write(f"# Анализ ненайденных пунктов для оптимальной конфигурации чанкинга\n\n")
439
+
440
+ # Параметры анализа
441
+ f.write("## Параметры анализа\n\n")
442
+ f.write(f"- **Модель**: {model_name}\n")
443
+ f.write(f"- **Размер чанка**: {words_per_chunk} слов\n")
444
+ f.write(f"- **Перекрытие**: {overlap_words} слов ({round(overlap_words/words_per_chunk*100, 1)}%)\n")
445
+ f.write(f"- **Количество чанков в топе**: {top_n}\n\n")
446
+
447
+ # Сводная статистика
448
+ total_questions = len(analysis_results)
449
+ total_puncts = sum(q['total_puncts_count'] for q in analysis_results.values())
450
+ total_found = sum(q['found_puncts_count'] for q in analysis_results.values())
451
+ total_missing = sum(q['missing_puncts_count'] for q in analysis_results.values())
452
+
453
+ f.write("## Сводная статистика\n\n")
454
+ f.write(f"- **Всего вопросов**: {total_questions}\n")
455
+ f.write(f"- **Всего пунктов**: {total_puncts}\n")
456
+ f.write(f"- **Найдено пунктов**: {total_found} ({round(total_found/total_puncts*100, 1)}%)\n")
457
+ f.write(f"- **Ненайдено пунктов**: {total_missing} ({round(total_missing/total_puncts*100, 1)}%)\n\n")
458
+
459
+ # Детали по каждому вопросу
460
+ f.write("## Детальный анализ по вопросам\n\n")
461
+
462
+ # Сортируем вопросы по количеству ненайденных пунктов (по убыванию)
463
+ sorted_questions = sorted(
464
+ analysis_results.values(),
465
+ key=lambda x: x['missing_puncts_count'],
466
+ reverse=True
467
+ )
468
+
469
+ for question_data in sorted_questions:
470
+ question_id = question_data['question_id']
471
+ question_text = question_data['question_text']
472
+ missing_count = question_data['missing_puncts_count']
473
+ total_count = question_data['total_puncts_count']
474
+
475
+ # Если нет ненайденных пунктов, пропускаем
476
+ if missing_count == 0:
477
+ continue
478
+
479
+ f.write(f"### Вопрос {question_id}\n\n")
480
+ f.write(f"**Текст вопроса**: {question_text}\n\n")
481
+ f.write(f"**Статистика**: найдено {question_data['found_puncts_count']} из {total_count} пунктов ")
482
+ f.write(f"({round(question_data['found_puncts_count']/total_count*100, 1)}%)\n\n")
483
+
484
+ # Детали по ненайденным пунктам
485
+ f.write("#### Ненайденные пункты\n\n")
486
+
487
+ for i, punct in enumerate(question_data['missing_puncts']):
488
+ punct_text = punct['text']
489
+ punct_doc = punct.get('doc_name', '')
490
+ similar_chunks = punct['similar_chunks']
491
+
492
+ f.write(f"##### Пункт {i+1}\n\n")
493
+ f.write(f"**Текст пункта**: {punct_text}\n\n")
494
+ if punct_doc:
495
+ f.write(f"**Документ пункта**: {punct_doc}\n\n")
496
+ f.write("**Топ-5 наиболее похожих чанков**:\n\n")
497
+
498
+ # Таблица с похожими чанками
499
+ f.write("| № | Документ | С��ожесть (с вопросом) | Перекрытие (с пунктом) | Текст чанка |\n")
500
+ f.write("|---|----------|----------|------------|------------|\n")
501
+
502
+ for j, chunk in enumerate(similar_chunks):
503
+ # Используем полный текст чанка без обрезки
504
+ chunk_text = chunk['text']
505
+
506
+ f.write(f"| {j+1} | {chunk['doc_name']} | {chunk['similarity']:.4f} | ")
507
+ f.write(f"{chunk['overlap']:.4f} | {chunk_text} |\n")
508
+
509
+ f.write("\n")
510
+
511
+ f.write("\n")
512
+
513
+ print(f"Отчет успешно сгенерирован: {output_file}")
514
+
515
+
516
+ def main():
517
+ """
518
+ Основная функция скрипта.
519
+ """
520
+ args = parse_args()
521
+
522
+ # Загружаем датасет с вопросами
523
+ questions_df = load_questions_dataset(args.dataset_path)
524
+
525
+ # Загружаем чанки и эмбеддинги
526
+ chunks_df, chunks_embeddings, questions_embeddings, questions_meta = load_chunks_and_embeddings(
527
+ args.output_dir, args.words_per_chunk, args.overlap_words, args.model_name
528
+ )
529
+
530
+ # Анализируем ненайденные пункты
531
+ analysis_results = analyze_missing_puncts(
532
+ questions_df, chunks_df, questions_embeddings, chunks_embeddings,
533
+ args.similarity_threshold, args.top_n
534
+ )
535
+
536
+ # Генерируем отчет в формате Markdown
537
+ output_file = os.path.join(args.output_dir, args.markdown_file)
538
+ generate_markdown_report(
539
+ analysis_results, output_file,
540
+ args.words_per_chunk, args.overlap_words, args.model_name, args.top_n
541
+ )
542
+
543
+ print(f"Анализ ненайденных пунктов завершен. Результаты сохранены в {output_file}")
544
+
545
+
546
+ if __name__ == "__main__":
547
+ main()
lib/extractor/scripts/combine_results.py ADDED
@@ -0,0 +1,1352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для объединения результатов всех экспериментов в одну Excel-таблицу с форматированием.
4
+ Анализирует результаты экспериментов и создает сводную таблицу с метриками в различных разрезах.
5
+ Также строит графики через seaborn и сохраняет их в отдельную директорию.
6
+ """
7
+
8
+ import argparse
9
+ import glob
10
+ import os
11
+
12
+ import matplotlib.pyplot as plt
13
+ import pandas as pd
14
+ import seaborn as sns
15
+ from openpyxl import Workbook
16
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
17
+ from openpyxl.utils import get_column_letter
18
+ from openpyxl.utils.dataframe import dataframe_to_rows
19
+
20
+
21
+ def setup_plot_directory(plots_dir: str) -> None:
22
+ """
23
+ Создает директорию для сохранения графиков, если она не существует.
24
+
25
+ Args:
26
+ plots_dir: Путь к директории для графиков
27
+ """
28
+ if not os.path.exists(plots_dir):
29
+ os.makedirs(plots_dir)
30
+ print(f"Создана директория для графиков: {plots_dir}")
31
+ else:
32
+ print(f"Директория для графиков: {plots_dir}")
33
+
34
+
35
+ def parse_args():
36
+ """Парсит аргументы командной строки."""
37
+ parser = argparse.ArgumentParser(description="Объединение результатов экспериментов в одну Excel-таблицу")
38
+
39
+ parser.add_argument("--results-dir", type=str, default="data",
40
+ help="Директория с результатами экспериментов (по умолчанию: data)")
41
+ parser.add_argument("--output-file", type=str, default="combined_results.xlsx",
42
+ help="Путь к выходному Excel-файлу (по умолчанию: combined_results.xlsx)")
43
+ parser.add_argument("--plots-dir", type=str, default="plots",
44
+ help="Директория для сохранения графиков (по умолчанию: plots)")
45
+
46
+ return parser.parse_args()
47
+
48
+
49
+ def parse_file_name(file_name: str) -> dict:
50
+ """
51
+ Парсит имя файла и извлекает параметры эксперимента.
52
+
53
+ Args:
54
+ file_name: Имя файла для парсинга
55
+
56
+ Returns:
57
+ Словарь с параметрами (words_per_chunk, overlap_words, model) или None при ошибке
58
+ """
59
+ try:
60
+ # Извлекаем параметры из имени файла
61
+ parts = file_name.split('_')
62
+ if len(parts) < 4:
63
+ return None
64
+
65
+ # Ищем части с w (words) и o (overlap)
66
+ words_part = None
67
+ overlap_part = None
68
+
69
+ for part in parts:
70
+ if part.startswith('w') and part[1:].isdigit():
71
+ words_part = part[1:]
72
+ elif part.startswith('o') and part[1:].isdigit():
73
+ # Убираем потенциальную часть .csv или .xlsx из overlap_part
74
+ overlap_part = part[1:].split('.')[0]
75
+
76
+ if words_part is None or overlap_part is None:
77
+ return None
78
+
79
+ # Пытаемся извлечь имя модели из оставшейся части имени файла
80
+ model_part = file_name.split(f"_w{words_part}_o{overlap_part}_", 1)
81
+ if len(model_part) < 2:
82
+ return None
83
+
84
+ # Получаем имя модели и удаляем возможное расширение файла
85
+ model_name_parts = model_part[1].split('.')
86
+ if len(model_name_parts) > 1:
87
+ model_name_parts = model_name_parts[:-1]
88
+
89
+ model_name_parts = '_'.join(model_name_parts).split('_')
90
+ model_name = '/'.join(model_name_parts)
91
+
92
+ return {
93
+ 'words_per_chunk': int(words_part),
94
+ 'overlap_words': int(overlap_part),
95
+ 'model': model_name,
96
+ 'overlap_percentage': round(int(overlap_part) / int(words_part) * 100, 1)
97
+ }
98
+ except Exception as e:
99
+ print(f"Ошибка при парсинге файла {file_name}: {e}")
100
+ return None
101
+
102
+
103
+ def load_data_files(results_dir: str, pattern: str, file_type: str, load_function) -> pd.DataFrame:
104
+ """
105
+ Общая функция для загрузки файлов данных с определенным паттерном имени.
106
+
107
+ Args:
108
+ results_dir: Директория с результатами
109
+ pattern: Glob-паттерн для поиска файлов
110
+ file_type: Тип файлов для сообщений (напр. "результатов", "метрик")
111
+ load_function: Функция для загрузки конкретного типа файла
112
+
113
+ Returns:
114
+ DataFrame с объединенными данными или None при ошибке
115
+ """
116
+ print(f"Загрузка {file_type} из {results_dir}...")
117
+
118
+ # Ищем все файлы с указанным паттерном
119
+ data_files = glob.glob(os.path.join(results_dir, pattern))
120
+
121
+ if not data_files:
122
+ print(f"В директории {results_dir} не найдены файлы {file_type}")
123
+ return None
124
+
125
+ print(f"Найдено {len(data_files)} файлов {file_type}")
126
+
127
+ all_data = []
128
+
129
+ for file_path in data_files:
130
+ # Извлекаем информацию о стратегии и модели из имени файла
131
+ file_name = os.path.basename(file_path)
132
+ print(f"Обрабатываю файл: {file_name}")
133
+
134
+ # Парсим параметры из имени файла
135
+ params = parse_file_name(file_name)
136
+
137
+ if params is None:
138
+ print(f"Пропуск файла {file_name}: не удалось извлечь параметры")
139
+ continue
140
+
141
+ words_part = params['words_per_chunk']
142
+ overlap_part = params['overlap_words']
143
+ model_name = params['model']
144
+ overlap_percentage = params['overlap_percentage']
145
+
146
+ print(f" Параметры: words={words_part}, overlap={overlap_part}, model={model_name}")
147
+
148
+ try:
149
+ # Загружаем данные, используя переданную функцию
150
+ df = load_function(file_path)
151
+
152
+ # Добавляем информацию о стратегии и модели
153
+ df['model'] = model_name
154
+ df['words_per_chunk'] = words_part
155
+ df['overlap_words'] = overlap_part
156
+ df['overlap_percentage'] = overlap_percentage
157
+
158
+ all_data.append(df)
159
+ except Exception as e:
160
+ print(f"Ошибка при обработке файла {file_path}: {e}")
161
+
162
+ if not all_data:
163
+ print(f"Не удалось загрузить ни один файл {file_type}")
164
+ return None
165
+
166
+ # Объединяем все данные
167
+ combined_data = pd.concat(all_data, ignore_index=True)
168
+
169
+ return combined_data
170
+
171
+
172
+ def load_results_files(results_dir: str) -> pd.DataFrame:
173
+ """
174
+ Загружает все файлы результатов из указанной директории.
175
+
176
+ Args:
177
+ results_dir: Директория с результатами
178
+
179
+ Returns:
180
+ DataFrame с объединенными результатами
181
+ """
182
+ # Используем общую функцию для загрузки CSV файлов
183
+ data = load_data_files(
184
+ results_dir,
185
+ "results_*.csv",
186
+ "результатов",
187
+ lambda f: pd.read_csv(f)
188
+ )
189
+
190
+ if data is None:
191
+ raise ValueError("Не удалось загрузить файлы с результатами")
192
+
193
+ return data
194
+
195
+
196
+ def load_question_metrics_files(results_dir: str) -> pd.DataFrame:
197
+ """
198
+ Загружает все файлы с метриками по вопросам из указанной директории.
199
+
200
+ Args:
201
+ results_dir: Директория с результатами
202
+
203
+ Returns:
204
+ DataFrame с объединенными метриками по вопросам или None, если файлов нет
205
+ """
206
+ # Используем общую функцию для загрузки Excel файлов
207
+ return load_data_files(
208
+ results_dir,
209
+ "question_metrics_*.xlsx",
210
+ "метрик по вопросам",
211
+ lambda f: pd.read_excel(f)
212
+ )
213
+
214
+
215
+ def prepare_summary_by_model_top_n(df: pd.DataFrame, macro_metrics: pd.DataFrame = None) -> pd.DataFrame:
216
+ """
217
+ Подготавливает сводную таблицу по моделям и top_n значениям.
218
+ Если доступны macro метрики, они также включаются в сводную таблицу.
219
+
220
+ Args:
221
+ df: DataFrame с объединенными результатами
222
+ macro_metrics: DataFrame с macro метриками (опционально)
223
+
224
+ Returns:
225
+ DataFrame со сводной таблицей
226
+ """
227
+ # Определяем группировочные колонки и метрики
228
+ group_by_columns = ['model', 'top_n']
229
+ metrics = ['text_precision', 'text_recall', 'text_f1', 'doc_precision', 'doc_recall', 'doc_f1']
230
+
231
+ # Используем общую функцию для подготовки сводки
232
+ return prepare_summary(df, group_by_columns, metrics, macro_metrics)
233
+
234
+
235
+ def prepare_summary_by_chunking_params_top_n(df: pd.DataFrame, macro_metrics: pd.DataFrame = None) -> pd.DataFrame:
236
+ """
237
+ Подготавливает сводную таблицу по параметрам чанкинга и top_n значениям.
238
+ Если доступны macro метрики, они также включаются в сводную таблицу.
239
+
240
+ Args:
241
+ df: DataFrame с объединенными результатами
242
+ macro_metrics: DataFrame с macro метриками (опционально)
243
+
244
+ Returns:
245
+ DataFrame со сводной таблицей
246
+ """
247
+ # Определяем группировочные колонки и метрики
248
+ group_by_columns = ['words_per_chunk', 'overlap_words', 'top_n']
249
+ metrics = ['text_precision', 'text_recall', 'text_f1', 'doc_precision', 'doc_recall', 'doc_f1']
250
+
251
+ # Используем общую функцию для подготовки сводки
252
+ return prepare_summary(df, group_by_columns, metrics, macro_metrics)
253
+
254
+
255
+ def prepare_summary(df: pd.DataFrame, group_by_columns: list, metrics: list, macro_metrics: pd.DataFrame = None) -> pd.DataFrame:
256
+ """
257
+ Общая функция для подготовки сводной таблицы по указанным группировочным колонкам.
258
+ Если доступны macro метрики, они также включаются в сводную таблицу.
259
+
260
+ Args:
261
+ df: DataFrame с объединенными результатами
262
+ group_by_columns: Колонки для группировки
263
+ metrics: Список метрик для расчета среднего
264
+ macro_metrics: DataFrame с macro метриками (опционально)
265
+
266
+ Returns:
267
+ DataFrame со сводной таблицей
268
+ """
269
+ # Группируем по указанным колонкам, вычисляем средние значения метрик
270
+ summary = df.groupby(group_by_columns).agg({
271
+ metric: 'mean' for metric in metrics
272
+ }).reset_index()
273
+
274
+ # Если среди группировочных колонок есть 'overlap_words' и 'words_per_chunk',
275
+ # добавляем процент перекрытия
276
+ if 'overlap_words' in group_by_columns and 'words_per_chunk' in group_by_columns:
277
+ summary['overlap_percentage'] = (summary['overlap_words'] / summary['words_per_chunk'] * 100).round(1)
278
+
279
+ # Если доступны macro метрики, объединяем их с summary
280
+ if macro_metrics is not None:
281
+ # Преобразуем метрики в macro_метрики
282
+ macro_metric_names = [f"macro_{metric}" for metric in metrics]
283
+
284
+ # Группируем macro метрики по тем же колонкам
285
+ macro_summary = macro_metrics.groupby(group_by_columns).agg({
286
+ metric: 'mean' for metric in macro_metric_names
287
+ }).reset_index()
288
+
289
+ # Если нужно, добавляем процент перекрытия для согласованности
290
+ if 'overlap_words' in group_by_columns and 'words_per_chunk' in group_by_columns:
291
+ macro_summary['overlap_percentage'] = (macro_summary['overlap_words'] / macro_summary['words_per_chunk'] * 100).round(1)
292
+ merge_on = group_by_columns + ['overlap_percentage']
293
+ else:
294
+ merge_on = group_by_columns
295
+
296
+ # Объединяем с основной сводкой
297
+ summary = pd.merge(summary, macro_summary, on=merge_on, how='left')
298
+
299
+ # Сортируем по группировочным колонкам
300
+ summary = summary.sort_values(group_by_columns)
301
+
302
+ # Округляем метрики до 4 знаков после запятой
303
+ for col in summary.columns:
304
+ if any(col.endswith(suffix) for suffix in ['precision', 'recall', 'f1']):
305
+ summary[col] = summary[col].round(4)
306
+
307
+ return summary
308
+
309
+
310
+ def prepare_best_configurations(df: pd.DataFrame, macro_metrics: pd.DataFrame = None) -> pd.DataFrame:
311
+ """
312
+ Подготавливает таблицу с лучшими конфигурациями для каждой модели и различных top_n.
313
+ Выбирает конфигурацию только на основе macro_text_recall и text_recall (weighted),
314
+ игнорируя F1 метрики как менее важные.
315
+
316
+ Args:
317
+ df: DataFrame с объединенными результатами
318
+ macro_metrics: DataFrame с macro метриками (опционально)
319
+
320
+ Returns:
321
+ DataFrame с лучшими конфигурациями
322
+ """
323
+ # Выбираем ключевые значения top_n
324
+ key_top_n = [10, 20, 50, 100]
325
+
326
+ # Определяем источник метрик и акцентируем только на recall-метриках
327
+ if macro_metrics is not None:
328
+ print("Выбор лучших конфигураций на основе macro метрик (macro_text_recall)")
329
+ metrics_source = macro_metrics
330
+ text_recall_metric = 'macro_text_recall'
331
+ doc_recall_metric = 'macro_doc_recall'
332
+ else:
333
+ print("Выбор лучших конфигураций на основе weighted метрик (text_recall)")
334
+ metrics_source = df
335
+ text_recall_metric = 'text_recall'
336
+ doc_recall_metric = 'doc_recall'
337
+
338
+ # Фильтруем только по ключевым значениям top_n
339
+ filtered_df = metrics_source[metrics_source['top_n'].isin(key_top_n)]
340
+
341
+ # Для каждой модели и top_n находим конфигурацию только с лучшим recall
342
+ best_configs = []
343
+
344
+ for model in metrics_source['model'].unique():
345
+ for top_n in key_top_n:
346
+ model_top_n_df = filtered_df[(filtered_df['model'] == model) & (filtered_df['top_n'] == top_n)]
347
+
348
+ if len(model_top_n_df) == 0:
349
+ continue
350
+
351
+ # Находим конфигурацию с лучшим text_recall
352
+ best_text_recall_idx = model_top_n_df[text_recall_metric].idxmax()
353
+ best_text_recall_config = model_top_n_df.loc[best_text_recall_idx].copy()
354
+ best_text_recall_config['metric_type'] = 'text_recall'
355
+
356
+ # Находим конфигурацию с лучшим doc_recall
357
+ best_doc_recall_idx = model_top_n_df[doc_recall_metric].idxmax()
358
+ best_doc_recall_config = model_top_n_df.loc[best_doc_recall_idx].copy()
359
+ best_doc_recall_config['metric_type'] = 'doc_recall'
360
+
361
+ best_configs.append(best_text_recall_config)
362
+ best_configs.append(best_doc_recall_config)
363
+
364
+ if not best_configs:
365
+ return pd.DataFrame()
366
+
367
+ best_configs_df = pd.DataFrame(best_configs)
368
+
369
+ # Выбираем и сортируем нужные столбцы
370
+ cols_to_keep = ['model', 'top_n', 'metric_type', 'words_per_chunk', 'overlap_words', 'overlap_percentage']
371
+
372
+ # Добавляем столбцы метрик в зависимости от того, какие доступны
373
+ if macro_metrics is not None:
374
+ # Для macro метрик сначала выбираем recall-метрики
375
+ recall_cols = [col for col in best_configs_df.columns if col.endswith('recall')]
376
+ # Затем добавляем остальные метрики
377
+ other_cols = [col for col in best_configs_df.columns if any(col.endswith(m) for m in
378
+ ['precision', 'f1']) and col.startswith('macro_')]
379
+ metric_cols = recall_cols + other_cols
380
+ else:
381
+ # Для weighted метрик сначала выбираем recall-метрики
382
+ recall_cols = [col for col in best_configs_df.columns if col.endswith('recall')]
383
+ # Затем добавляем остальные метрики
384
+ other_cols = [col for col in best_configs_df.columns if any(col.endswith(m) for m in
385
+ ['precision', 'f1']) and not col.startswith('macro_')]
386
+ metric_cols = recall_cols + other_cols
387
+
388
+ result = best_configs_df[cols_to_keep + metric_cols].sort_values(['model', 'top_n', 'metric_type'])
389
+
390
+ return result
391
+
392
+
393
+ def get_grouping_columns(sheet) -> dict:
394
+ """
395
+ Определяет подходящие колонки для группировки данных на листе.
396
+
397
+ Args:
398
+ sheet: Лист Excel
399
+
400
+ Returns:
401
+ Словарь с данными о группировке или None
402
+ """
403
+ # Возможные варианты группировки
404
+ grouping_possibilities = [
405
+ {'columns': ['model', 'words_per_chunk', 'overlap_words']},
406
+ {'columns': ['model']},
407
+ {'columns': ['words_per_chunk', 'overlap_words']},
408
+ {'columns': ['top_n']},
409
+ {'columns': ['model', 'top_n', 'metric_type']}
410
+ ]
411
+
412
+ # Для каждого варианта группировки проверяем наличие всех колонок
413
+ for grouping in grouping_possibilities:
414
+ column_indices = {}
415
+ all_columns_present = True
416
+
417
+ for column_name in grouping['columns']:
418
+ column_idx = None
419
+ for col_idx, cell in enumerate(sheet[1], start=1):
420
+ if cell.value == column_name:
421
+ column_idx = col_idx
422
+ break
423
+
424
+ if column_idx is None:
425
+ all_columns_present = False
426
+ break
427
+ else:
428
+ column_indices[column_name] = column_idx
429
+
430
+ if all_columns_present:
431
+ return {
432
+ 'columns': grouping['columns'],
433
+ 'indices': column_indices
434
+ }
435
+
436
+ return None
437
+
438
+
439
+ def apply_header_formatting(sheet):
440
+ """
441
+ Применяет форматирование к заголовкам.
442
+
443
+ Args:
444
+ sheet: Лист Excel
445
+ """
446
+ # Форматирование заголовков
447
+ for cell in sheet[1]:
448
+ cell.font = Font(bold=True)
449
+ cell.fill = PatternFill(start_color="D9D9D9", end_color="D9D9D9", fill_type="solid")
450
+ cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
451
+
452
+
453
+ def adjust_column_width(sheet):
454
+ """
455
+ Настраивает ширину столбцов на основе содержимого.
456
+
457
+ Args:
458
+ sheet: Лист Excel
459
+ """
460
+ # Авторазмер столбцов
461
+ for column in sheet.columns:
462
+ max_length = 0
463
+ column_letter = get_column_letter(column[0].column)
464
+
465
+ for cell in column:
466
+ if cell.value:
467
+ try:
468
+ if len(str(cell.value)) > max_length:
469
+ max_length = len(str(cell.value))
470
+ except:
471
+ pass
472
+
473
+ adjusted_width = (max_length + 2) * 1.1
474
+ sheet.column_dimensions[column_letter].width = adjusted_width
475
+
476
+
477
+ def apply_cell_formatting(sheet):
478
+ """
479
+ Применяет форматирование к ячейкам (границы, выравнивание и т.д.).
480
+
481
+ Args:
482
+ sheet: Лист Excel
483
+ """
484
+ # Тонкие границы для всех ячеек
485
+ thin_border = Border(
486
+ left=Side(style='thin'),
487
+ right=Side(style='thin'),
488
+ top=Side(style='thin'),
489
+ bottom=Side(style='thin')
490
+ )
491
+
492
+ for row in sheet.iter_rows(min_row=1, max_row=sheet.max_row, min_col=1, max_col=sheet.max_column):
493
+ for cell in row:
494
+ cell.border = thin_border
495
+
496
+ # Форматирование числовых значений
497
+ numeric_columns = [
498
+ 'text_precision', 'text_recall', 'text_f1',
499
+ 'doc_precision', 'doc_recall', 'doc_f1',
500
+ 'macro_text_precision', 'macro_text_recall', 'macro_text_f1',
501
+ 'macro_doc_precision', 'macro_doc_recall', 'macro_doc_f1'
502
+ ]
503
+
504
+ for col_idx, header in enumerate(sheet[1], start=1):
505
+ if header.value in numeric_columns or (header.value and str(header.value).endswith(('precision', 'recall', 'f1'))):
506
+ for row_idx in range(2, sheet.max_row + 1):
507
+ cell = sheet.cell(row=row_idx, column=col_idx)
508
+ if isinstance(cell.value, (int, float)):
509
+ cell.number_format = '0.0000'
510
+
511
+ # Выравнивание для всех ячеек
512
+ for row in sheet.iter_rows(min_row=2, max_row=sheet.max_row, min_col=1, max_col=sheet.max_column):
513
+ for cell in row:
514
+ cell.alignment = Alignment(horizontal='center', vertical='center')
515
+
516
+
517
+ def apply_group_formatting(sheet, grouping):
518
+ """
519
+ Применяет форматирование к группам строк.
520
+
521
+ Args:
522
+ sheet: Лист Excel
523
+ grouping: Словарь с данными о группировке
524
+ """
525
+ if not grouping or sheet.max_row <= 1:
526
+ return
527
+
528
+ # Для каждой строки проверяем изменение значений группировочных колонок
529
+ last_values = {column: None for column in grouping['columns']}
530
+
531
+ # Применяем жирную верхнюю границу к первой строке данных
532
+ for col_idx in range(1, sheet.max_column + 1):
533
+ cell = sheet.cell(row=2, column=col_idx)
534
+ cell.border = Border(
535
+ left=cell.border.left,
536
+ right=cell.border.right,
537
+ top=Side(style='thick'),
538
+ bottom=cell.border.bottom
539
+ )
540
+
541
+ for row_idx in range(2, sheet.max_row + 1):
542
+ current_values = {}
543
+ for column in grouping['columns']:
544
+ col_idx = grouping['indices'][column]
545
+ current_values[column] = sheet.cell(row=row_idx, column=col_idx).value
546
+
547
+ # Если значения изменились, добавляем жирные границы
548
+ values_changed = False
549
+ for column in grouping['columns']:
550
+ if current_values[column] != last_values[column]:
551
+ values_changed = True
552
+ break
553
+
554
+ if values_changed and row_idx > 2:
555
+ # Жирная верхняя граница для текущей строки
556
+ for col_idx in range(1, sheet.max_column + 1):
557
+ cell = sheet.cell(row=row_idx, column=col_idx)
558
+ cell.border = Border(
559
+ left=cell.border.left,
560
+ right=cell.border.right,
561
+ top=Side(style='thick'),
562
+ bottom=cell.border.bottom
563
+ )
564
+
565
+ # Жирная нижняя граница для предыдущей строки
566
+ for col_idx in range(1, sheet.max_column + 1):
567
+ cell = sheet.cell(row=row_idx-1, column=col_idx)
568
+ cell.border = Border(
569
+ left=cell.border.left,
570
+ right=cell.border.right,
571
+ top=cell.border.top,
572
+ bottom=Side(style='thick')
573
+ )
574
+
575
+ # Запоминаем текущие значения для следующей итерации
576
+ for column in grouping['columns']:
577
+ last_values[column] = current_values[column]
578
+
579
+ # Добавляем жирную нижнюю границу для последней строки
580
+ for col_idx in range(1, sheet.max_column + 1):
581
+ cell = sheet.cell(row=sheet.max_row, column=col_idx)
582
+ cell.border = Border(
583
+ left=cell.border.left,
584
+ right=cell.border.right,
585
+ top=cell.border.top,
586
+ bottom=Side(style='thick')
587
+ )
588
+
589
+
590
+ def apply_formatting(workbook: Workbook) -> None:
591
+ """
592
+ Применяет форматирование к Excel-файлу.
593
+ Добавляет автофильтры для всех столбцов и улучшает визуальное представление.
594
+
595
+ Args:
596
+ workbook: Workbook-объект openpyxl
597
+ """
598
+ for sheet_name in workbook.sheetnames:
599
+ sheet = workbook[sheet_name]
600
+
601
+ # Добавляем автофильтры для всех столбцов
602
+ if sheet.max_row > 1: # Проверяем, что в листе есть данные
603
+ sheet.auto_filter.ref = sheet.dimensions
604
+
605
+ # Применяем форматирование
606
+ apply_header_formatting(sheet)
607
+ adjust_column_width(sheet)
608
+ apply_cell_formatting(sheet)
609
+
610
+ # Определяем группирующие колонки и применяем форматирование к группам
611
+ grouping = get_grouping_columns(sheet)
612
+ if grouping:
613
+ apply_group_formatting(sheet, grouping)
614
+
615
+
616
+ def create_model_comparison_plot(df: pd.DataFrame, metrics: list | str, top_n: int, plots_dir: str) -> None:
617
+ """
618
+ Создает график сравнения моделей по указанным метрикам для заданного top_n.
619
+
620
+ Args:
621
+ df: DataFrame с данными
622
+ metrics: Список метрик или одна метрика для сравнения
623
+ top_n: Значение top_n для фильтрации
624
+ plots_dir: Директория для сохранения графиков
625
+ """
626
+ if isinstance(metrics, str):
627
+ metrics = [metrics]
628
+
629
+ # Фильтруем данные
630
+ filtered_df = df[df['top_n'] == top_n]
631
+
632
+ if len(filtered_df) == 0:
633
+ print(f"Нет данных для top_n={top_n}")
634
+ return
635
+
636
+ # Определяем тип метрик (macro или weighted)
637
+ metrics_type = "macro" if metrics[0].startswith("macro_") else "weighted"
638
+
639
+ # Создаем фигуру с несколькими подграфиками
640
+ fig, axes = plt.subplots(1, len(metrics), figsize=(6 * len(metrics), 8))
641
+
642
+ # Если только одна метрика, преобразуем axes в список для единообразного обращения
643
+ if len(metrics) == 1:
644
+ axes = [axes]
645
+
646
+ # Для каждой метрики создаем subplot
647
+ for i, metric in enumerate(metrics):
648
+ # Группируем данные по модели
649
+ columns_to_agg = {metric: 'mean'}
650
+ model_data = filtered_df.groupby('model').agg(columns_to_agg).reset_index()
651
+
652
+ # Сортируем по значению метрики (по убыванию)
653
+ model_data = model_data.sort_values(metric, ascending=False)
654
+
655
+ # Определяем цветовую схему
656
+ palette = sns.color_palette("viridis", len(model_data))
657
+
658
+ # Строим столбчатую диаграмму на соответствующем subplot
659
+ ax = sns.barplot(x='model', y=metric, data=model_data, palette=palette, ax=axes[i])
660
+
661
+ # Добавляем значения над столбцами
662
+ for j, v in enumerate(model_data[metric]):
663
+ ax.text(j, v + 0.01, f"{v:.4f}", ha='center', fontsize=8)
664
+
665
+ # Устанавливаем заголовок и метки осей
666
+ ax.set_title(f"{metric} (top_n={top_n})", fontsize=12)
667
+ ax.set_xlabel("Модель", fontsize=10)
668
+ ax.set_ylabel(f"{metric}", fontsize=10)
669
+
670
+ # Поворачиваем подписи по оси X для лучшей читаемости
671
+ ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha='right', fontsize=8)
672
+
673
+ # Настраиваем макет
674
+ plt.tight_layout()
675
+
676
+ # Сохраняем график
677
+ metric_names = '_'.join([m.replace('macro_', '') for m in metrics])
678
+ file_name = f"model_comparison_{metrics_type}_{metric_names}_top{top_n}.png"
679
+ plt.savefig(os.path.join(plots_dir, file_name), dpi=300)
680
+ plt.close()
681
+
682
+ print(f"Создан график сравнения моделей: {file_name}")
683
+
684
+
685
+ def create_top_n_plot(df: pd.DataFrame, models: list | str, metric: str, plots_dir: str) -> None:
686
+ """
687
+ Создает график зависимости метрики от top_n для заданных моделей.
688
+
689
+ Args:
690
+ df: DataFrame с данными
691
+ models: Список моделей или одна модель для сравнения
692
+ metric: Название метрики
693
+ plots_dir: Директория для сохранения графиков
694
+ """
695
+ if isinstance(models, str):
696
+ models = [models]
697
+
698
+ # Создаем фигуру
699
+ plt.figure(figsize=(12, 8))
700
+
701
+ # Определяем цветовую схему
702
+ palette = sns.color_palette("viridis", len(models))
703
+
704
+ # Ограничиваем количество моделей для читаемости
705
+ if len(models) > 5:
706
+ models = models[:5]
707
+ print("Слишком много моделей для графика, ограничиваем до 5")
708
+
709
+ # Для каждой модели строим линию
710
+ for i, model in enumerate(models):
711
+ # Находим наиболее часто используемые параметры чанкинга для этой модели
712
+ model_df = df[df['model'] == model]
713
+
714
+ if len(model_df) == 0:
715
+ print(f"Нет данных для модели {model}")
716
+ continue
717
+
718
+ # Группируем по параметрам чанкинга и подсчитываем частоту
719
+ common_configs = model_df.groupby(['words_per_chunk', 'overlap_words']).size().reset_index(name='count')
720
+
721
+ if len(common_configs) == 0:
722
+ continue
723
+
724
+ # Берем наиболее частую конфигурацию
725
+ common_config = common_configs.sort_values('count', ascending=False).iloc[0]
726
+
727
+ # Фильтруем для этой конфигурации
728
+ config_df = model_df[
729
+ (model_df['words_per_chunk'] == common_config['words_per_chunk']) &
730
+ (model_df['overlap_words'] == common_config['overlap_words'])
731
+ ].sort_values('top_n')
732
+
733
+ if len(config_df) <= 1:
734
+ continue
735
+
736
+ # Строим линию
737
+ plt.plot(config_df['top_n'], config_df[metric], marker='o', linewidth=2,
738
+ label=f"{model} (w={common_config['words_per_chunk']}, o={common_config['overlap_words']})",
739
+ color=palette[i])
740
+
741
+ # Добавляем легенду, заголовок и метки осей
742
+ plt.legend(title="Модель (параметры)", fontsize=10, loc='best')
743
+ plt.title(f"Зависимость {metric} от top_n для разных моделей", fontsize=16)
744
+ plt.xlabel("top_n", fontsize=14)
745
+ plt.ylabel(metric, fontsize=14)
746
+
747
+ # Включаем сетку
748
+ plt.grid(True, linestyle='--', alpha=0.7)
749
+
750
+ # Настраиваем макет
751
+ plt.tight_layout()
752
+
753
+ # Сохраняем график
754
+ is_macro = "macro" if "macro" in metric else "weighted"
755
+ file_name = f"top_n_comparison_{is_macro}_{metric.replace('macro_', '')}.png"
756
+ plt.savefig(os.path.join(plots_dir, file_name), dpi=300)
757
+ plt.close()
758
+
759
+ print(f"Создан график зависимости от top_n: {file_name}")
760
+
761
+
762
+ def create_chunk_size_plot(df: pd.DataFrame, model: str, metrics: list | str, top_n: int, plots_dir: str) -> None:
763
+ """
764
+ Создает график зависимости метрик от размера чанка для заданной модели и top_n.
765
+
766
+ Args:
767
+ df: DataFrame с данными
768
+ model: Название модели
769
+ metrics: Список метрик или одна метрика
770
+ top_n: Значение top_n
771
+ plots_dir: Директория для сохранения графиков
772
+ """
773
+ if isinstance(metrics, str):
774
+ metrics = [metrics]
775
+
776
+ # Фильтруем данные
777
+ filtered_df = df[(df['model'] == model) & (df['top_n'] == top_n)]
778
+
779
+ if len(filtered_df) <= 1:
780
+ print(f"Недостаточно данных для модели {model} и top_n={top_n}")
781
+ return
782
+
783
+ # Создаем фигуру
784
+ plt.figure(figsize=(14, 8))
785
+
786
+ # Определяем цветовую схему для метрик
787
+ palette = sns.color_palette("viridis", len(metrics))
788
+
789
+ # Группируем по размеру чанка и проценту перекрытия
790
+ # Вычисляем среднее только для указанных метрик, а не для всех столбцов
791
+ columns_to_agg = {metric: 'mean' for metric in metrics}
792
+ chunk_data = filtered_df.groupby(['words_per_chunk', 'overlap_percentage']).agg(columns_to_agg).reset_index()
793
+
794
+ # Получаем уникальные значения процента перекрытия
795
+ overlap_percentages = sorted(chunk_data['overlap_percentage'].unique())
796
+
797
+ # Настраиваем маркеры и линии для разных перекрытий
798
+ markers = ['o', 's', '^', 'D', 'x', '*']
799
+
800
+ # Для каждого перекрытия строим линии с разными метриками
801
+ for i, overlap in enumerate(overlap_percentages):
802
+ subset = chunk_data[chunk_data['overlap_percentage'] == overlap].sort_values('words_per_chunk')
803
+
804
+ for j, metric in enumerate(metrics):
805
+ plt.plot(subset['words_per_chunk'], subset[metric],
806
+ marker=markers[i % len(markers)], linewidth=2,
807
+ label=f"{metric}, overlap={overlap}%",
808
+ color=palette[j])
809
+
810
+ # Добавляем легенду и заголовок
811
+ plt.legend(title="Метрика и перекрытие", fontsize=10, loc='best')
812
+ plt.title(f"Зависимость метрик от размера чанка для {model} (top_n={top_n})", fontsize=16)
813
+ plt.xlabel("Размер чанка (слов)", fontsize=14)
814
+ plt.ylabel("Значение метрики", fontsize=14)
815
+
816
+ # Включаем сетку
817
+ plt.grid(True, linestyle='--', alpha=0.7)
818
+
819
+ # Настраиваем макет
820
+ plt.tight_layout()
821
+
822
+ # Сохраняем график
823
+ metrics_type = "macro" if metrics[0].startswith("macro_") else "weighted"
824
+ model_name = model.replace('/', '_')
825
+ metric_names = '_'.join([m.replace('macro_', '') for m in metrics])
826
+ file_name = f"chunk_size_{metrics_type}_{metric_names}_{model_name}_top{top_n}.png"
827
+ plt.savefig(os.path.join(plots_dir, file_name), dpi=300)
828
+ plt.close()
829
+
830
+ print(f"Создан график зависимости от размера чанка: {file_name}")
831
+
832
+
833
+ def create_heatmap(df: pd.DataFrame, models: list | str, metric: str, top_n: int, plots_dir: str) -> None:
834
+ """
835
+ Создает тепловые карты зависимости метрики от размера чанка и процента перекрытия
836
+ для заданных моделей.
837
+
838
+ Args:
839
+ df: DataFrame с данными
840
+ models: Список моделей или одна модель
841
+ metric: Название метрики
842
+ top_n: Значение top_n
843
+ plots_dir: Директория для сохранения графиков
844
+ """
845
+ if isinstance(models, str):
846
+ models = [models]
847
+
848
+ # Ограничиваем количество моделей для наглядности
849
+ if len(models) > 4:
850
+ models = models[:4]
851
+
852
+ # Создаем фигуру с подграфиками
853
+ fig, axes = plt.subplots(1, len(models), figsize=(6 * len(models), 6), squeeze=False)
854
+
855
+ # Для каждой модели создаем тепловую карту
856
+ for i, model in enumerate(models):
857
+ # Фильтруем данные для указанной модели и top_n
858
+ filtered_df = df[(df['model'] == model) & (df['top_n'] == top_n)]
859
+
860
+ # Проверяем, достаточно ли данных для построения тепловой карты
861
+ chunk_sizes = filtered_df['words_per_chunk'].unique()
862
+ overlap_percentages = filtered_df['overlap_percentage'].unique()
863
+
864
+ if len(chunk_sizes) <= 1 or len(overlap_percentages) <= 1:
865
+ print(f"Недостаточно данных для построения тепловой карты для модели {model} и top_n={top_n}")
866
+ # Пропускаем этот subplot
867
+ axes[0, i].text(0.5, 0.5, f"Недостаточно данных для {model}",
868
+ horizontalalignment='center', verticalalignment='center')
869
+ axes[0, i].set_title(model)
870
+ axes[0, i].axis('off')
871
+ continue
872
+
873
+ # Создаем сводную таблицу для тепловой карты, используя только нужную метрику
874
+ # Сначала выберем только колонки для pivot_table
875
+ pivot_columns = ['words_per_chunk', 'overlap_percentage', metric]
876
+ pivot_df = filtered_df[pivot_columns].copy()
877
+
878
+ # Теперь создаем сводную таблицу
879
+ pivot_data = pivot_df.pivot_table(
880
+ index='words_per_chunk',
881
+ columns='overlap_percentage',
882
+ values=metric,
883
+ aggfunc='mean'
884
+ )
885
+
886
+ # Строим тепловую карту
887
+ sns.heatmap(pivot_data, annot=True, fmt=".4f", cmap="viridis",
888
+ linewidths=.5, annot_kws={"size": 8}, ax=axes[0, i])
889
+
890
+ # Устанавливаем заголовок и метки осей
891
+ axes[0, i].set_title(model, fontsize=12)
892
+ axes[0, i].set_xlabel("Процент перекрытия (%)", fontsize=10)
893
+ axes[0, i].set_ylabel("Размер чанка (слов)", fontsize=10)
894
+
895
+ # Добавляем общий заголовок
896
+ plt.suptitle(f"Тепловые карты {metric} для разных моделей (top_n={top_n})", fontsize=16)
897
+
898
+ # Настраиваем макет
899
+ plt.tight_layout(rect=[0, 0, 1, 0.96]) # Оставляем место для общего заголовка
900
+
901
+ # Сохраняем график
902
+ is_macro = "macro" if "macro" in metric else "weighted"
903
+ file_name = f"heatmap_{is_macro}_{metric.replace('macro_', '')}_top{top_n}.png"
904
+ plt.savefig(os.path.join(plots_dir, file_name), dpi=300)
905
+ plt.close()
906
+
907
+ print(f"Созданы тепловые карты: {file_name}")
908
+
909
+
910
+ def find_best_combinations(df: pd.DataFrame, metrics: list | str = None) -> pd.DataFrame:
911
+ """
912
+ Находит наилучшие комбинации параметров на основе агрегированных recall-метрик.
913
+
914
+ Args:
915
+ df: DataFrame с данными
916
+ metrics: Список метрик для анализа или None (тогда используются все recall-метрики)
917
+
918
+ Returns:
919
+ DataFrame с лучшими комбинациями параметров
920
+ """
921
+ if metrics is None:
922
+ # По умолчанию выбираем все метрики с "recall" в названии
923
+ metrics = [col for col in df.columns if "recall" in col]
924
+ elif isinstance(metrics, str):
925
+ metrics = [metrics]
926
+
927
+ print(f"Поиск лучших комбинаций на основе метрик: {metrics}")
928
+
929
+ # Создаем новую метрику - сумму всех указанных recall-метрик
930
+ df_copy = df.copy()
931
+ df_copy['combined_recall'] = df_copy[metrics].sum(axis=1)
932
+
933
+ # Находим лучшие комбинации для различных значений top_n
934
+ best_combinations = []
935
+
936
+ for top_n in df_copy['top_n'].unique():
937
+ top_n_df = df_copy[df_copy['top_n'] == top_n]
938
+
939
+ if len(top_n_df) == 0:
940
+ continue
941
+
942
+ # Находим строку с максимальным combined_recall
943
+ best_idx = top_n_df['combined_recall'].idxmax()
944
+ best_row = top_n_df.loc[best_idx].copy()
945
+ best_row['best_for_top_n'] = top_n
946
+
947
+ best_combinations.append(best_row)
948
+
949
+ # Находим лучшие комбинации для разных моделей
950
+ for model in df_copy['model'].unique():
951
+ model_df = df_copy[df_copy['model'] == model]
952
+
953
+ if len(model_df) == 0:
954
+ continue
955
+
956
+ # Находим строку с максимальным combined_recall
957
+ best_idx = model_df['combined_recall'].idxmax()
958
+ best_row = model_df.loc[best_idx].copy()
959
+ best_row['best_for_model'] = model
960
+
961
+ best_combinations.append(best_row)
962
+
963
+ # Находим лучшие комбинации для разных размеров чанков
964
+ for chunk_size in df_copy['words_per_chunk'].unique():
965
+ chunk_df = df_copy[df_copy['words_per_chunk'] == chunk_size]
966
+
967
+ if len(chunk_df) == 0:
968
+ continue
969
+
970
+ # Находим строку с максимальным combined_recall
971
+ best_idx = chunk_df['combined_recall'].idxmax()
972
+ best_row = chunk_df.loc[best_idx].copy()
973
+ best_row['best_for_chunk_size'] = chunk_size
974
+
975
+ best_combinations.append(best_row)
976
+
977
+ # Находим абсолютно лучшую комбинацию
978
+ if len(df_copy) > 0:
979
+ best_idx = df_copy['combined_recall'].idxmax()
980
+ best_row = df_copy.loc[best_idx].copy()
981
+ best_row['absolute_best'] = True
982
+
983
+ best_combinations.append(best_row)
984
+
985
+ if not best_combinations:
986
+ return pd.DataFrame()
987
+
988
+ result = pd.DataFrame(best_combinations)
989
+
990
+ # Сортируем по combined_recall (по убыванию)
991
+ result = result.sort_values('combined_recall', ascending=False)
992
+
993
+ print(f"Найдено {len(result)} лучших комбинаций")
994
+
995
+ return result
996
+
997
+
998
+ def create_best_combinations_plot(best_df: pd.DataFrame, metrics: list | str, plots_dir: str) -> None:
999
+ """
1000
+ Создает график сравнения лучших комбинаций параметров.
1001
+
1002
+ Args:
1003
+ best_df: DataFrame с лучшими комбинациями
1004
+ metrics: Список метрик для визуализаци��
1005
+ plots_dir: Директория для сохранения графиков
1006
+ """
1007
+ if isinstance(metrics, str):
1008
+ metrics = [metrics]
1009
+
1010
+ if len(best_df) == 0:
1011
+ print("Нет данных для построения графика лучших комбинаций")
1012
+ return
1013
+
1014
+ # Создаем новый признак для идентификации комбинаций
1015
+ best_df['combo_label'] = best_df.apply(
1016
+ lambda row: f"{row['model']} (w={row['words_per_chunk']}, o={row['overlap_words']}, top_n={row['top_n']})",
1017
+ axis=1
1018
+ )
1019
+
1020
+ # Берем только лучшие N комбинаций для читаемости
1021
+ max_combos = 10
1022
+ if len(best_df) > max_combos:
1023
+ plot_df = best_df.head(max_combos).copy()
1024
+ print(f"Ограничиваем график до {max_combos} лучших комбинаций")
1025
+ else:
1026
+ plot_df = best_df.copy()
1027
+
1028
+ # Создаем длинный формат данных для seaborn
1029
+ plot_data = plot_df.melt(
1030
+ id_vars=['combo_label', 'combined_recall'],
1031
+ value_vars=metrics,
1032
+ var_name='metric',
1033
+ value_name='value'
1034
+ )
1035
+
1036
+ # Сортируем по суммарному recall (комбинации) и метрике (для группировки)
1037
+ plot_data = plot_data.sort_values(['combined_recall', 'metric'], ascending=[False, True])
1038
+
1039
+ # Создаем фигуру для графика
1040
+ plt.figure(figsize=(14, 10))
1041
+
1042
+ # Создаем bar plot
1043
+ sns.barplot(
1044
+ x='combo_label',
1045
+ y='value',
1046
+ hue='metric',
1047
+ data=plot_data,
1048
+ palette='viridis'
1049
+ )
1050
+
1051
+ # Настраиваем оси и заголовок
1052
+ plt.title('Лучшие комбинации параметров по recall-метрикам', fontsize=16)
1053
+ plt.xlabel('Комбинация параметров', fontsize=14)
1054
+ plt.ylabel('Значение метрики', fontsize=14)
1055
+
1056
+ # Поворачиваем подписи по оси X для лучшей читаемости
1057
+ plt.xticks(rotation=45, ha='right', fontsize=10)
1058
+
1059
+ # Настраиваем легенду
1060
+ plt.legend(title='Метрика', fontsize=12)
1061
+
1062
+ # Добавляем сетку
1063
+ plt.grid(axis='y', linestyle='--', alpha=0.7)
1064
+
1065
+ # Настраиваем макет
1066
+ plt.tight_layout()
1067
+
1068
+ # Сохраняем график
1069
+ file_name = f"best_combinations_comparison.png"
1070
+ plt.savefig(os.path.join(plots_dir, file_name), dpi=300)
1071
+ plt.close()
1072
+
1073
+ print(f"Создан график сравнения лучших комбинаций: {file_name}")
1074
+
1075
+
1076
+ def generate_plots(combined_results: pd.DataFrame, macro_metrics: pd.DataFrame, plots_dir: str) -> None:
1077
+ """
1078
+ Генерирует набор графиков с помощью seaborn и сохраняет их в указанную директорию.
1079
+ Фокусируется в первую очередь на recall-метриках как наиболее важных.
1080
+
1081
+ Args:
1082
+ combined_results: DataFrame с объединенными результатами (weighted метрики)
1083
+ macro_metrics: DataFrame с macro метриками
1084
+ plots_dir: Директория для сохранения графиков
1085
+ """
1086
+ # Создаем директорию для графиков, если она не существует
1087
+ setup_plot_directory(plots_dir)
1088
+
1089
+ # Настраиваем стиль для графиков
1090
+ sns.set_style("whitegrid")
1091
+ plt.rcParams['font.family'] = 'DejaVu Sans'
1092
+
1093
+ # Получаем список моделей для построения графиков
1094
+ models = combined_results['model'].unique()
1095
+ top_n_values = [10, 20, 50, 100]
1096
+
1097
+ print(f"Генерация графиков для {len(models)} моделей...")
1098
+
1099
+ # 0. Добавляем анализ наилучших комбинаций параметров
1100
+ # Определяем метрики для анализа - фокусируемся на recall
1101
+ weighted_recall_metrics = ['text_recall', 'doc_recall']
1102
+
1103
+ # Находим лучшие комбинации параметров
1104
+ best_combinations = find_best_combinations(combined_results, weighted_recall_metrics)
1105
+
1106
+ # Создаем график сравнения лучших комбинаций
1107
+ if not best_combinations.empty:
1108
+ create_best_combinations_plot(best_combinations, weighted_recall_metrics, plots_dir)
1109
+
1110
+ # Если доступны macro метрики, делаем то же самое для них
1111
+ if macro_metrics is not None:
1112
+ macro_recall_metrics = ['macro_text_recall', 'macro_doc_recall']
1113
+ macro_best_combinations = find_best_combinations(macro_metrics, macro_recall_metrics)
1114
+
1115
+ if not macro_best_combinations.empty:
1116
+ create_best_combinations_plot(macro_best_combinations, macro_recall_metrics, plots_dir)
1117
+
1118
+ # 1. Создаем графики сравнения моделей для weighted метрик
1119
+ # Фокусируемся на recall-метриках
1120
+ weighted_metrics = {
1121
+ 'text': ['text_recall'], # Только text_recall
1122
+ 'doc': ['doc_recall'] # Только doc_recall
1123
+ }
1124
+
1125
+ for top_n in top_n_values:
1126
+ for metrics_group, metrics in weighted_metrics.items():
1127
+ create_model_comparison_plot(combined_results, metrics, top_n, plots_dir)
1128
+
1129
+ # 2. Если доступны macro метрики, создаем графики на их основе
1130
+ if macro_metrics is not None:
1131
+ print("Создание графиков на основе macro метрик...")
1132
+ macro_metrics_groups = {
1133
+ 'text': ['macro_text_recall'], # Только macro_text_recall
1134
+ 'doc': ['macro_doc_recall'] # Только macro_doc_recall
1135
+ }
1136
+
1137
+ for top_n in top_n_values:
1138
+ for metrics_group, metrics in macro_metrics_groups.items():
1139
+ create_model_comparison_plot(macro_metrics, metrics, top_n, plots_dir)
1140
+
1141
+ # 3. Создаем графики зависимости от top_n
1142
+ for metrics_type, df in [("weighted", combined_results), ("macro", macro_metrics)]:
1143
+ if df is None:
1144
+ continue
1145
+
1146
+ metrics_to_plot = []
1147
+ if metrics_type == "weighted":
1148
+ metrics_to_plot = ['text_recall', 'doc_recall'] # Только recall-метрики
1149
+ else:
1150
+ metrics_to_plot = ['macro_text_recall', 'macro_doc_recall'] # Только macro recall-метрики
1151
+
1152
+ for metric in metrics_to_plot:
1153
+ create_top_n_plot(df, models, metric, plots_dir)
1154
+
1155
+ # 4. Для каждой модели создаем графики по размеру чанка
1156
+ for model in models:
1157
+ # Выбираем 2 значения top_n для анализа
1158
+ for top_n in [20, 50]:
1159
+ # Создаем графики с recall-метриками
1160
+ weighted_metrics_to_combine = ['text_recall']
1161
+ create_chunk_size_plot(combined_results, model, weighted_metrics_to_combine, top_n, plots_dir)
1162
+
1163
+ doc_metrics_to_combine = ['doc_recall']
1164
+ create_chunk_size_plot(combined_results, model, doc_metrics_to_combine, top_n, plots_dir)
1165
+
1166
+ # Если есть macro метрики, создаем соответствующие графики
1167
+ if macro_metrics is not None:
1168
+ macro_metrics_to_combine = ['macro_text_recall']
1169
+ create_chunk_size_plot(macro_metrics, model, macro_metrics_to_combine, top_n, plots_dir)
1170
+
1171
+ macro_doc_metrics_to_combine = ['macro_doc_recall']
1172
+ create_chunk_size_plot(macro_metrics, model, macro_doc_metrics_to_combine, top_n, plots_dir)
1173
+
1174
+ # 5. Создаем тепловые карты для моделей
1175
+ for top_n in [20, 50]:
1176
+ for metric_prefix in ["", "macro_"]:
1177
+ for metric_type in ["text_recall", "doc_recall"]:
1178
+ metric = f"{metric_prefix}{metric_type}"
1179
+ # Используем соответствующий DataFrame
1180
+ if metric_prefix and macro_metrics is None:
1181
+ continue
1182
+ df_to_use = macro_metrics if metric_prefix else combined_results
1183
+ create_heatmap(df_to_use, models, metric, top_n, plots_dir)
1184
+
1185
+ print(f"Создание графиков завершено в директории {plots_dir}")
1186
+
1187
+
1188
+ def print_best_combinations(best_df: pd.DataFrame) -> None:
1189
+ """
1190
+ Выводит информацию о лучших комбинациях параметров.
1191
+
1192
+ Args:
1193
+ best_df: DataFrame с лучшими комбинациями
1194
+ """
1195
+ if best_df.empty:
1196
+ print("Не найдено лучших комбинаций")
1197
+ return
1198
+
1199
+ print("\n=== ЛУЧШИЕ КОМБИНАЦИИ ПАРАМЕТРОВ ===")
1200
+
1201
+ # Выводим абсолютно лучшую комбинацию, если она есть
1202
+ absolute_best = best_df[best_df.get('absolute_best', False) == True]
1203
+ if not absolute_best.empty:
1204
+ row = absolute_best.iloc[0]
1205
+ print(f"\nАБСОЛЮТНО ЛУЧШАЯ КОМБИНАЦИЯ:")
1206
+ print(f" Модель: {row['model']}")
1207
+ print(f" Размер чанка: {row['words_per_chunk']} слов")
1208
+ print(f" Перекрытие: {row['overlap_words']} слов ({row['overlap_percentage']}%)")
1209
+ print(f" top_n: {row['top_n']}")
1210
+
1211
+ # Выводим значения метрик
1212
+ recall_metrics = [col for col in best_df.columns if 'recall' in col and col != 'combined_recall']
1213
+ for metric in recall_metrics:
1214
+ print(f" {metric}: {row[metric]:.4f}")
1215
+
1216
+ print("\n=== ТОП-5 ЛУЧШИХ КОМБИНАЦИЙ ===")
1217
+ for i, row in best_df.head(5).iterrows():
1218
+ print(f"\n#{i+1}: {row['model']}, w={row['words_per_chunk']}, o={row['overlap_words']}, top_n={row['top_n']}")
1219
+
1220
+ # Выводим значения метрик
1221
+ recall_metrics = [col for col in best_df.columns if 'recall' in col and col != 'combined_recall']
1222
+ for metric in recall_metrics:
1223
+ print(f" {metric}: {row[metric]:.4f}")
1224
+
1225
+ print("\n=======================================")
1226
+
1227
+
1228
+ def create_combined_excel(combined_results: pd.DataFrame, question_metrics: pd.DataFrame,
1229
+ macro_metrics: pd.DataFrame = None, output_file: str = "combined_results.xlsx") -> None:
1230
+ """
1231
+ Создает Excel-файл с несколькими листами, содержащими различные срезы данных.
1232
+ Добавляет автофильтры и применяет форматирование.
1233
+
1234
+ Args:
1235
+ combined_results: DataFrame с объединенными результатами
1236
+ question_metrics: DataFrame с метриками по вопросам
1237
+ macro_metrics: DataFrame с macro метриками (опционально)
1238
+ output_file: Путь к выходному Excel-файлу
1239
+ """
1240
+ print(f"Создание Excel-файла {output_file}...")
1241
+
1242
+ # Создаем новый Excel-файл
1243
+ workbook = Workbook()
1244
+
1245
+ # Удаляем стандартный лист
1246
+ default_sheet = workbook.active
1247
+ workbook.remove(default_sheet)
1248
+
1249
+ # Подготавливаем данные для различных листов
1250
+ sheets_data = {
1251
+ "Исходные данные": combined_results,
1252
+ "Сводка по моделям": prepare_summary_by_model_top_n(combined_results, macro_metrics),
1253
+ "Сводка по чанкингу": prepare_summary_by_chunking_params_top_n(combined_results, macro_metrics),
1254
+ "Лучшие конфигурации": prepare_best_configurations(combined_results, macro_metrics)
1255
+ }
1256
+
1257
+ # Если есть метрики по вопросам, добавляем лист с ними
1258
+ if question_metrics is not None:
1259
+ sheets_data["Метрики по вопросам"] = question_metrics
1260
+
1261
+ # Если есть macro метрики, добавляем лист с ними
1262
+ if macro_metrics is not None:
1263
+ sheets_data["Macro метрики"] = macro_metrics
1264
+
1265
+ # Создаем листы и добавляем данные
1266
+ for sheet_name, data in sheets_data.items():
1267
+ if data is not None and not data.empty:
1268
+ sheet = workbook.create_sheet(title=sheet_name)
1269
+ for r in dataframe_to_rows(data, index=False, header=True):
1270
+ sheet.append(r)
1271
+
1272
+ # Применяем форматирование
1273
+ apply_formatting(workbook)
1274
+
1275
+ # Сохраняем файл
1276
+ workbook.save(output_file)
1277
+ print(f"Excel-файл создан: {output_file}")
1278
+
1279
+
1280
+ def calculate_macro_metrics(question_metrics: pd.DataFrame) -> pd.DataFrame:
1281
+ """
1282
+ Вычисляет macro метрики на основе результатов по вопросам.
1283
+
1284
+ Args:
1285
+ question_metrics: DataFrame с метриками по вопросам
1286
+
1287
+ Returns:
1288
+ DataFrame с macro метриками
1289
+ """
1290
+ if question_metrics is None:
1291
+ return None
1292
+
1293
+ print("Вычисление macro метрик на основе метрик по вопросам...")
1294
+
1295
+ # Группируем по конфигурации (модель, параметры чанкинга, top_n)
1296
+ grouped_metrics = question_metrics.groupby(['model', 'words_per_chunk', 'overlap_words', 'top_n'])
1297
+
1298
+ # Для каждой группы вычисляем среднее значение метрик (macro)
1299
+ macro_metrics = grouped_metrics.agg({
1300
+ 'text_precision': 'mean', # Macro precision = среднее precision по всем вопросам
1301
+ 'text_recall': 'mean', # Macro recall = среднее recall по всем вопросам
1302
+ 'text_f1': 'mean', # Macro F1 = среднее F1 по всем вопросам
1303
+ 'doc_precision': 'mean',
1304
+ 'doc_recall': 'mean',
1305
+ 'doc_f1': 'mean'
1306
+ }).reset_index()
1307
+
1308
+ # Добавляем префикс "macro_" к названиям метрик для ясности
1309
+ for col in ['text_precision', 'text_recall', 'text_f1', 'doc_precision', 'doc_recall', 'doc_f1']:
1310
+ macro_metrics.rename(columns={col: f'macro_{col}'}, inplace=True)
1311
+
1312
+ # Добавляем процент перекрытия
1313
+ macro_metrics['overlap_percentage'] = (macro_metrics['overlap_words'] / macro_metrics['words_per_chunk'] * 100).round(1)
1314
+
1315
+ print(f"Вычислено {len(macro_metrics)} набо��ов macro метрик")
1316
+
1317
+ return macro_metrics
1318
+
1319
+
1320
+ def main():
1321
+ """Основная функция скрипта."""
1322
+ args = parse_args()
1323
+
1324
+ # Загружаем результаты из CSV-файлов
1325
+ combined_results = load_results_files(args.results_dir)
1326
+
1327
+ # Загружаем метрики по вопросам (если есть)
1328
+ question_metrics = load_question_metrics_files(args.results_dir)
1329
+
1330
+ # Вычисляем macro метрики на основе метрик по вопросам
1331
+ macro_metrics = calculate_macro_metrics(question_metrics)
1332
+
1333
+ # Находим лучшие комбинации параметров
1334
+ best_combinations_weighted = find_best_combinations(combined_results, ['text_recall', 'doc_recall'])
1335
+ print_best_combinations(best_combinations_weighted)
1336
+
1337
+ if macro_metrics is not None:
1338
+ best_combinations_macro = find_best_combinations(macro_metrics, ['macro_text_recall', 'macro_doc_recall'])
1339
+ print_best_combinations(best_combinations_macro)
1340
+
1341
+ # Создаем объединенный Excel-файл с данными
1342
+ create_combined_excel(combined_results, question_metrics, macro_metrics, args.output_file)
1343
+
1344
+ # Генерируем графики с помощью seaborn
1345
+ print(f"Генерация графиков и сохранение их в директорию: {args.plots_dir}")
1346
+ generate_plots(combined_results, macro_metrics, args.plots_dir)
1347
+
1348
+ print("Готово! Результаты сохранены в Excel и графики созданы.")
1349
+
1350
+
1351
+ if __name__ == "__main__":
1352
+ main()
lib/extractor/scripts/debug_question_chunks.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для отладки и анализа чанков, найденных для конкретного вопроса.
4
+ Показывает, какие чанки находятся, какие пункты ожидаются и значения метрик нечеткого сравнения.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ from difflib import SequenceMatcher
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from sklearn.metrics.pairwise import cosine_similarity
17
+
18
+ sys.path.insert(0, str(Path(__file__).parent.parent))
19
+
20
+
21
+ # Константы для настройки
22
+ DATA_FOLDER = "data/docs" # Путь к папке с документами
23
+ MODEL_NAME = "intfloat/e5-base" # Название модели для векторизации
24
+ DATASET_PATH = "data/dataset.xlsx" # Путь к Excel-датасету с вопросами
25
+ OUTPUT_DIR = "data" # Директория для сохранения результатов
26
+ TOP_N_VALUES = [5, 10, 20, 30, 50, 100] # Значения N для анализа
27
+ THRESHOLD = 0.6
28
+
29
+
30
+ def parse_args():
31
+ """
32
+ Парсит аргументы командной строки.
33
+
34
+ Returns:
35
+ Аргументы командной строки
36
+ """
37
+ parser = argparse.ArgumentParser(description="Скрипт для отладки чанкинга на конкретном вопросе")
38
+
39
+ parser.add_argument("--data-folder", type=str, default=DATA_FOLDER,
40
+ help=f"Путь к папке с документами (по умолчанию: {DATA_FOLDER})")
41
+ parser.add_argument("--model-name", type=str, default=MODEL_NAME,
42
+ help=f"Название модели для векторизации (по умолчанию: {MODEL_NAME})")
43
+ parser.add_argument("--dataset-path", type=str, default=DATASET_PATH,
44
+ help=f"Путь к Excel-датасету с вопросами (по умолчанию: {DATASET_PATH})")
45
+ parser.add_argument("--output-dir", type=str, default=OUTPUT_DIR,
46
+ help=f"Директория для сохранения результатов (по умолчанию: {OUTPUT_DIR})")
47
+ parser.add_argument("--question-id", type=int, required=True,
48
+ help="ID вопроса для отладки")
49
+ parser.add_argument("--top-n", type=int, default=20,
50
+ help="Количество чанков в топе для отладки (по умолчанию: 20)")
51
+ parser.add_argument("--words-per-chunk", type=int, default=50,
52
+ help="Количество слов в чанке для fixed_size стратегии (по умолчанию: 50)")
53
+ parser.add_argument("--overlap-words", type=int, default=25,
54
+ help="Количество слов перекрытия для fixed_size стратегии (по умолчанию: 25)")
55
+
56
+ return parser.parse_args()
57
+
58
+
59
+ def load_questions_dataset(file_path: str) -> pd.DataFrame:
60
+ """
61
+ Загружает датасет с вопросами из Excel-файла.
62
+
63
+ Args:
64
+ file_path: Путь к Excel-файлу
65
+
66
+ Returns:
67
+ DataFrame с вопросами и пунктами
68
+ """
69
+ print(f"Загрузка датасета из {file_path}...")
70
+
71
+ df = pd.read_excel(file_path)
72
+ print(f"Загружен датасет со столбцами: {df.columns.tolist()}")
73
+
74
+ # Преобразуем NaN в пустые строки для текстовых полей
75
+ text_columns = ['question', 'text', 'item_type']
76
+ for col in text_columns:
77
+ if col in df.columns:
78
+ df[col] = df[col].fillna('')
79
+
80
+ return df
81
+
82
+
83
+ def load_embeddings_and_data(filename: str, output_dir: str) -> tuple[np.ndarray | None, pd.DataFrame | None]:
84
+ """
85
+ Загружает эмбеддинги и соответствующие данные из файлов.
86
+
87
+ Args:
88
+ filename: Базовое имя файла
89
+ output_dir: Директория, где хранятся файлы
90
+
91
+ Returns:
92
+ Кортеж (эмбеддинги, данные) или (None, None), если файлы не найдены
93
+ """
94
+ embeddings_path = os.path.join(output_dir, f"{filename}_embeddings.npy")
95
+ data_path = os.path.join(output_dir, f"{filename}_data.csv")
96
+
97
+ if os.path.exists(embeddings_path) and os.path.exists(data_path):
98
+ print(f"Загрузка данных из {embeddings_path} и {data_path}...")
99
+ embeddings = np.load(embeddings_path)
100
+ data = pd.read_csv(data_path)
101
+ return embeddings, data
102
+
103
+ print(f"Ошибка: файлы {embeddings_path} и {data_path} не найдены.")
104
+ print("Сначала запустите скрип�� evaluate_chunking.py для создания эмбеддингов.")
105
+ sys.exit(1)
106
+
107
+
108
+ def calculate_chunk_overlap(chunk_text: str, punct_text: str) -> float:
109
+ """
110
+ Рассчитывает степень перекрытия между чанком и пунктом.
111
+
112
+ Args:
113
+ chunk_text: Текст чанка
114
+ punct_text: Текст пункта
115
+
116
+ Returns:
117
+ Коэффициент перекрытия от 0 до 1
118
+ """
119
+ # Если чанк входит в пункт, возвращаем 1.0 (полное вхождение)
120
+ if chunk_text in punct_text:
121
+ return 1.0
122
+
123
+ # Если пункт входит в чанк, возвращаем соотношение длин
124
+ if punct_text in chunk_text:
125
+ return len(punct_text) / len(chunk_text)
126
+
127
+ # Используем SequenceMatcher для нечеткого сравнения
128
+ matcher = SequenceMatcher(None, chunk_text, punct_text)
129
+
130
+ # Находим наибольшую общую подстроку
131
+ match = matcher.find_longest_match(0, len(chunk_text), 0, len(punct_text))
132
+
133
+ # Если совпадений нет
134
+ if match.size == 0:
135
+ return 0.0
136
+
137
+ # Возвращаем соотношение длины совпадения к минимальной длине
138
+ return match.size / min(len(chunk_text), len(punct_text))
139
+
140
+
141
+ def format_text_for_display(text: str, max_length: int = 100) -> str:
142
+ """
143
+ Форматирует текст для отображения, обрезая его при необходимости.
144
+
145
+ Args:
146
+ text: Исходный текст
147
+ max_length: Максимальная длина для отображения
148
+
149
+ Returns:
150
+ Отформатированный текст
151
+ """
152
+ if len(text) <= max_length:
153
+ return text
154
+ return text[:max_length] + "..."
155
+
156
+
157
+ def analyze_question(
158
+ question_id: int,
159
+ questions_df: pd.DataFrame,
160
+ chunks_df: pd.DataFrame,
161
+ question_embeddings: np.ndarray,
162
+ chunk_embeddings: np.ndarray,
163
+ question_id_to_idx: dict,
164
+ top_n: int
165
+ ) -> dict:
166
+ """
167
+ Анализирует конкретный вопрос и его релевантные чанки.
168
+
169
+ Args:
170
+ question_id: ID вопроса для анализа
171
+ questions_df: DataFrame с вопросами
172
+ chunks_df: DataFrame с чанками
173
+ question_embeddings: Эмбеддинги вопросов
174
+ chunk_embeddings: Эмбеддинги чанков
175
+ question_id_to_idx: Словарь соответствия ID вопроса и его индекса
176
+ top_n: Количество чанков в топе
177
+
178
+ Returns:
179
+ Словарь с результатами анализа
180
+ """
181
+ # Проверяем, есть ли вопрос с таким ID
182
+ if question_id not in question_id_to_idx:
183
+ print(f"Ошибка: вопрос с ID {question_id} не найден в данных")
184
+ sys.exit(1)
185
+
186
+ # Получаем строки для выбранного вопроса
187
+ question_rows = questions_df[questions_df['id'] == question_id]
188
+ if len(question_rows) == 0:
189
+ print(f"Ошибка: вопрос с ID {question_id} не найден в исходном датасете")
190
+ sys.exit(1)
191
+
192
+ # Получаем текст вопроса и его индекс в массиве эмбеддингов
193
+ question_text = question_rows['question'].iloc[0]
194
+ question_idx = question_id_to_idx[question_id]
195
+
196
+ # Получаем ожидаемые пункты для вопроса
197
+ expected_puncts = question_rows['text'].tolist()
198
+
199
+ # Вычисляем косинусную близость между вопросом и всеми чанками
200
+ similarity = cosine_similarity([question_embeddings[question_idx]], chunk_embeddings)[0]
201
+
202
+ # Получаем связанные документы, если есть
203
+ related_docs = []
204
+ if 'filename' in question_rows.columns:
205
+ related_docs = question_rows['filename'].unique().tolist()
206
+ related_docs = [doc for doc in related_docs if doc and not pd.isna(doc)]
207
+
208
+ # Результаты для всех документов
209
+ all_results = []
210
+
211
+ # Обрабатываем каждый связанный документ
212
+ if related_docs:
213
+ for doc_name in related_docs:
214
+ # Фильтруем чанки по имени документа
215
+ doc_chunks = chunks_df[chunks_df['doc_name'] == doc_name]
216
+ if doc_chunks.empty:
217
+ continue
218
+
219
+ # Индексы чанков для документа
220
+ doc_chunk_indices = doc_chunks.index.tolist()
221
+
222
+ # Получаем значения близости для чанков документа
223
+ doc_similarities = [similarity[chunks_df.index.get_loc(idx)] for idx in doc_chunk_indices]
224
+
225
+ # Создаем словарь индекс -> схожесть
226
+ similarity_dict = {idx: sim for idx, sim in zip(doc_chunk_indices, doc_similarities)}
227
+
228
+ # Сортируем индексы по убыванию похожести
229
+ sorted_indices = sorted(similarity_dict.keys(), key=lambda x: similarity_dict[x], reverse=True)
230
+
231
+ # Берем топ-N
232
+ top_indices = sorted_indices[:min(top_n, len(sorted_indices))]
233
+
234
+ # Получаем топ-N чанков
235
+ top_chunks = chunks_df.iloc[top_indices]
236
+
237
+ # Формируем результаты для документа
238
+ doc_results = {
239
+ 'doc_name': doc_name,
240
+ 'top_chunks': []
241
+ }
242
+
243
+ # Для каждого чанка
244
+ for idx, chunk in top_chunks.iterrows():
245
+ # Вычисляем перекрытие с каждым пунктом
246
+ overlaps = []
247
+ for punct in expected_puncts:
248
+ overlap = calculate_chunk_overlap(chunk['text'], punct)
249
+ overlaps.append({
250
+ 'punct': format_text_for_display(punct),
251
+ 'overlap': overlap
252
+ })
253
+
254
+ # Находим максимальное перекрытие
255
+ max_overlap = max(overlaps, key=lambda x: x['overlap']) if overlaps else {'overlap': 0}
256
+
257
+ # Добавляем в результаты
258
+ doc_results['top_chunks'].append({
259
+ 'chunk_id': chunk['id'],
260
+ 'chunk_text': format_text_for_display(chunk['text']),
261
+ 'similarity': similarity_dict[idx],
262
+ 'overlaps': overlaps,
263
+ 'max_overlap': max_overlap['overlap'],
264
+ 'is_relevant': max_overlap['overlap'] >= THRESHOLD # Используем порог 0.7
265
+ })
266
+
267
+ all_results.append(doc_results)
268
+ else:
269
+ # Если нет связанных документов, анализируем чанки из всех документов
270
+ # Получаем индексы для топ-N чанков по близости
271
+ top_indices = np.argsort(similarity)[-top_n:][::-1]
272
+
273
+ # Получаем топ-N чанков
274
+ top_chunks = chunks_df.iloc[top_indices]
275
+
276
+ # Группируем чанки по документам
277
+ doc_groups = top_chunks.groupby('doc_name')
278
+
279
+ for doc_name, group in doc_groups:
280
+ doc_results = {
281
+ 'doc_name': doc_name,
282
+ 'top_chunks': []
283
+ }
284
+
285
+ for idx, chunk in group.iterrows():
286
+ # Вычисляем перекрытие с каждым пунктом
287
+ overlaps = []
288
+ for punct in expected_puncts:
289
+ overlap = calculate_chunk_overlap(chunk['text'], punct)
290
+ overlaps.append({
291
+ 'punct': format_text_for_display(punct),
292
+ 'overlap': overlap
293
+ })
294
+
295
+ # Находим максимальное перекрытие
296
+ max_overlap = max(overlaps, key=lambda x: x['overlap']) if overlaps else {'overlap': 0}
297
+
298
+ # Добавляем в результаты
299
+ doc_results['top_chunks'].append({
300
+ 'chunk_id': chunk['id'],
301
+ 'chunk_text': format_text_for_display(chunk['text']),
302
+ 'similarity': similarity[chunks_df.index.get_loc(idx)],
303
+ 'overlaps': overlaps,
304
+ 'max_overlap': max_overlap['overlap'],
305
+ 'is_relevant': max_overlap['overlap'] >= THRESHOLD # Используем порог 0.7
306
+ })
307
+
308
+ all_results.append(doc_results)
309
+
310
+ # Формируем общие результаты для вопроса
311
+ results = {
312
+ 'question_id': question_id,
313
+ 'question_text': question_text,
314
+ 'expected_puncts': [format_text_for_display(punct) for punct in expected_puncts],
315
+ 'related_docs': related_docs,
316
+ 'results_by_doc': all_results
317
+ }
318
+
319
+ return results
320
+
321
+
322
+ def main():
323
+ """
324
+ Основная функция скрипта.
325
+ """
326
+ args = parse_args()
327
+
328
+ # Загружаем датасет с вопросами
329
+ questions_df = load_questions_dataset(args.dataset_path)
330
+
331
+ # Формируем уникальное имя для сохраненных файлов на основе параметров стратегии и модел��
332
+ strategy_config_str = f"fixed_size_w{args.words_per_chunk}_o{args.overlap_words}"
333
+ chunks_filename = f"chunks_{strategy_config_str}_{args.model_name.replace('/', '_')}"
334
+ questions_filename = f"questions_{args.model_name.replace('/', '_')}"
335
+
336
+ # Загружаем сохраненные эмбеддинги и данные
337
+ chunk_embeddings, chunks_df = load_embeddings_and_data(chunks_filename, args.output_dir)
338
+ question_embeddings, questions_df_with_embeddings = load_embeddings_and_data(questions_filename, args.output_dir)
339
+
340
+ # Создаем словарь соответствия id вопроса и его индекса в эмбеддингах
341
+ question_id_to_idx = {
342
+ int(row['id']): i
343
+ for i, (_, row) in enumerate(questions_df_with_embeddings.iterrows())
344
+ }
345
+
346
+ # Анализируем выбранный вопрос для указанного top_n
347
+ results = analyze_question(
348
+ args.question_id,
349
+ questions_df,
350
+ chunks_df,
351
+ question_embeddings,
352
+ chunk_embeddings,
353
+ question_id_to_idx,
354
+ args.top_n
355
+ )
356
+
357
+ # Сохраняем результаты в JSON файл
358
+ output_filename = f"debug_question_{args.question_id}_top{args.top_n}.json"
359
+ output_path = os.path.join(args.output_dir, output_filename)
360
+
361
+ with open(output_path, 'w', encoding='utf-8') as f:
362
+ json.dump(results, f, ensure_ascii=False, indent=2)
363
+
364
+ print(f"Результаты сохранены в {output_path}")
365
+
366
+ # Выводим краткую информацию
367
+ print(f"\nАнализ вопроса ID {args.question_id}: {results['question_text']}")
368
+ print(f"Ожидаемые пункты: {len(results['expected_puncts'])}")
369
+ print(f"Связанные документы: {results['related_docs']}")
370
+
371
+ # Статистика релевантности
372
+ relevant_chunks = 0
373
+ total_chunks = 0
374
+
375
+ for doc_result in results['results_by_doc']:
376
+ doc_relevant = sum(1 for chunk in doc_result['top_chunks'] if chunk['is_relevant'])
377
+ doc_total = len(doc_result['top_chunks'])
378
+
379
+ print(f"\nДокумент: {doc_result['doc_name']}")
380
+ print(f"Релевантных чанков: {doc_relevant} из {doc_total} ({doc_relevant/doc_total*100:.1f}%)")
381
+
382
+ relevant_chunks += doc_relevant
383
+ total_chunks += doc_total
384
+
385
+ if total_chunks > 0:
386
+ print(f"\nОбщая точность: {relevant_chunks/total_chunks*100:.1f}%")
387
+ else:
388
+ print("\nНе найдено чанков для анализа")
389
+
390
+
391
+ if __name__ == "__main__":
392
+ main()
lib/extractor/scripts/evaluate_chunking.py ADDED
@@ -0,0 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для оценки качества различных стратегий чанкинга.
4
+ Сравнивает стратегии на основе релевантности чанков к вопросам.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import torch
16
+ from fuzzywuzzy import fuzz
17
+ from sklearn.metrics.pairwise import cosine_similarity
18
+ from tqdm import tqdm
19
+ from transformers import AutoModel, AutoTokenizer
20
+
21
+ # Константы для настройки
22
+ DATA_FOLDER = "data/docs" # Путь к папке с документами
23
+ MODEL_NAME = "intfloat/e5-base" # Название модели для векторизации
24
+ DATASET_PATH = "data/dataset.xlsx" # Путь к Excel-датасету с вопросами
25
+ BATCH_SIZE = 8 # Размер батча для векторизации
26
+ DEVICE = "cuda:1" if torch.cuda.is_available() else "cpu" # Устройство для вычислений
27
+ SIMILARITY_THRESHOLD = 0.7 # Порог для нечеткого сравнения
28
+ OUTPUT_DIR = "data" # Директория для сохранения результатов
29
+ TOP_CHUNKS_DIR = "data/top_chunks" # Директория для сохранения топ-чанков
30
+ TOP_N_VALUES = [5, 10, 20, 30, 50, 70, 100] # Значения N для оценки
31
+
32
+ # Параметры стратегий чанкинга
33
+ FIXED_SIZE_CONFIG = {
34
+ "words_per_chunk": 50, # Количество слов в чанке
35
+ "overlap_words": 25 # Количество слов перекрытия
36
+ }
37
+
38
+ sys.path.insert(0, str(Path(__file__).parent.parent))
39
+ from ntr_fileparser import UniversalParser
40
+
41
+ from ntr_text_fragmentation import Destructurer
42
+
43
+
44
+ def _average_pool(
45
+ last_hidden_states: torch.Tensor, attention_mask: torch.Tensor
46
+ ) -> torch.Tensor:
47
+ """
48
+ Расчёт усредненного эмбеддинга по всем токенам
49
+
50
+ Args:
51
+ last_hidden_states: Матрица эмбеддингов отдельных токенов размерности (batch_size, seq_len, embedding_size) - последний скрытый слой
52
+ attention_mask: Маска, чтобы не учитывать при усреднении пустые токены
53
+
54
+ Returns:
55
+ torch.Tensor - Усредненный эмбеддинг размерности (batch_size, embedding_size)
56
+ """
57
+ last_hidden = last_hidden_states.masked_fill(
58
+ ~attention_mask[..., None].bool(), 0.0
59
+ )
60
+ return last_hidden.sum(dim=1) / attention_mask.sum(dim=1)[..., None]
61
+
62
+
63
+ def parse_args():
64
+ """
65
+ Парсит аргументы командной строки.
66
+
67
+ Returns:
68
+ Аргументы командной строки
69
+ """
70
+ parser = argparse.ArgumentParser(description="Скрипт для оценки качества чанкинга")
71
+
72
+ parser.add_argument("--data-folder", type=str, default=DATA_FOLDER,
73
+ help=f"Путь к папке с документами (по умолчанию: {DATA_FOLDER})")
74
+ parser.add_argument("--model-name", type=str, default=MODEL_NAME,
75
+ help=f"Название модели для векторизации (по умолчанию: {MODEL_NAME})")
76
+ parser.add_argument("--dataset-path", type=str, default=DATASET_PATH,
77
+ help=f"Путь к Excel-датасету с вопросами (по умолчанию: {DATASET_PATH})")
78
+ parser.add_argument("--batch-size", type=int, default=BATCH_SIZE,
79
+ help=f"Размер батча для векторизации (по умолчанию: {BATCH_SIZE})")
80
+ parser.add_argument("--similarity-threshold", type=float, default=SIMILARITY_THRESHOLD,
81
+ help=f"Порог для нечеткого сравнения (по умолчанию: {SIMILARITY_THRESHOLD})")
82
+ parser.add_argument("--output-dir", type=str, default=OUTPUT_DIR,
83
+ help=f"Директория для сохранения результатов (по умолчанию: {OUTPUT_DIR})")
84
+ parser.add_argument("--force-recompute", action="store_true",
85
+ help="Принудительно пересчитать эмбеддинги, игнорируя сохраненные")
86
+ parser.add_argument("--use-sentence-transformers", action="store_true",
87
+ help="Использовать библиотеку sentence_transformers для извлечения эмбеддингов (для FRIDA и других моделей)")
88
+ parser.add_argument("--device", type=str, default=DEVICE,
89
+ help=f"Устройст��о для вычислений (по умолчанию: {DEVICE})")
90
+
91
+ # Параметры для fixed_size стратегии
92
+ parser.add_argument("--words-per-chunk", type=int, default=FIXED_SIZE_CONFIG["words_per_chunk"],
93
+ help=f"Количество слов в чанке для fixed_size стратегии (по умолчанию: {FIXED_SIZE_CONFIG['words_per_chunk']})")
94
+ parser.add_argument("--overlap-words", type=int, default=FIXED_SIZE_CONFIG["overlap_words"],
95
+ help=f"Количество слов перекрытия для fixed_size стратегии (по умолчанию: {FIXED_SIZE_CONFIG['overlap_words']})")
96
+
97
+ return parser.parse_args()
98
+
99
+
100
+ def read_documents(folder_path: str) -> dict:
101
+ """
102
+ Читает все документы из указанной папки.
103
+
104
+ Args:
105
+ folder_path: Путь к папке с документами
106
+
107
+ Returns:
108
+ Словарь {имя_файла: parsed_document}
109
+ """
110
+ print(f"Чтение документов из {folder_path}...")
111
+ parser = UniversalParser()
112
+ documents = {}
113
+
114
+ for file_path in tqdm(list(Path(folder_path).glob("*.docx")), desc="Чтение документов"):
115
+ try:
116
+ doc_name = file_path.stem
117
+ documents[doc_name] = parser.parse_by_path(str(file_path))
118
+ except Exception as e:
119
+ print(f"Ошибка при чтении файла {file_path}: {e}")
120
+
121
+ return documents
122
+
123
+
124
+ def process_documents(documents: dict, fixed_size_config: dict) -> pd.DataFrame:
125
+ """
126
+ Обрабатывает документы со стратегией fixed_size для чанкинга.
127
+
128
+ Args:
129
+ documents: Словарь с распарсенными документами
130
+ fixed_size_config: Конфигурация для fixed_size стратегии
131
+
132
+ Returns:
133
+ DataFrame с чанками
134
+ """
135
+ print("Обработка документов стратегией fixed_size...")
136
+
137
+ all_data = []
138
+
139
+ for doc_name, document in tqdm(documents.items(), desc="Применение стратегии fixed_size"):
140
+ # Стратегия fixed_size для чанкинга
141
+ destructurer = Destructurer(document)
142
+ destructurer.configure('fixed_size',
143
+ words_per_chunk=fixed_size_config["words_per_chunk"],
144
+ overlap_words=fixed_size_config["overlap_words"])
145
+ fixed_size_entities, _ = destructurer.destructure()
146
+
147
+ # Обрабатываем только сущности для поиска
148
+ for entity in fixed_size_entities:
149
+ if hasattr(entity, 'use_in_search') and entity.use_in_search:
150
+ entity_data = {
151
+ 'id': str(entity.id),
152
+ 'doc_name': doc_name,
153
+ 'name': entity.name,
154
+ 'text': entity.text,
155
+ 'type': entity.type,
156
+ 'strategy': 'fixed_size',
157
+ 'metadata': json.dumps(entity.metadata, ensure_ascii=False)
158
+ }
159
+ all_data.append(entity_data)
160
+
161
+ # Создаем DataFrame
162
+ df = pd.DataFrame(all_data)
163
+
164
+ # Фильтруем по типу, исключая Document
165
+ df = df[df['type'] != 'Document']
166
+
167
+ return df
168
+
169
+
170
+ def load_questions_dataset(file_path: str) -> pd.DataFrame:
171
+ """
172
+ Загружает датасет с вопросами из Excel-файла.
173
+
174
+ Args:
175
+ file_path: Путь к Excel-файлу
176
+
177
+ Returns:
178
+ DataFrame с вопросами и пунктами
179
+ """
180
+ print(f"Загрузка датасета из {file_path}...")
181
+
182
+ df = pd.read_excel(file_path)
183
+ print(f"Загружен датасет со столбцами: {df.columns.tolist()}")
184
+
185
+ # Преобразуем NaN в пустые строки для текстовых полей
186
+ text_columns = ['question', 'text', 'item_type']
187
+ for col in text_columns:
188
+ if col in df.columns:
189
+ df[col] = df[col].fillna('')
190
+
191
+ return df
192
+
193
+
194
+ def setup_model_and_tokenizer(model_name: str, use_sentence_transformers: bool = False, device: str = DEVICE):
195
+ """
196
+ Инициализирует модель и токенизатор.
197
+
198
+ Args:
199
+ model_name: Название предобученной модели
200
+ use_sentence_transformers: Использовать ли библиотеку sentence_transformers
201
+ device: Устройство для вычислений
202
+
203
+ Returns:
204
+ Кортеж (модель, токенизатор) или объект SentenceTransformer
205
+ """
206
+ print(f"Загрузка модели {model_name} на устройство {device}...")
207
+
208
+ if use_sentence_transformers:
209
+ try:
210
+ from sentence_transformers import SentenceTransformer
211
+ model = SentenceTransformer(model_name, device=device)
212
+ return model, None
213
+ except ImportError:
214
+ print("Библиотека sentence_transformers не установлена. Установите её с помощью pip install sentence-transformers")
215
+ raise
216
+ else:
217
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
218
+ model = AutoModel.from_pretrained(model_name).to(device)
219
+ model.eval()
220
+
221
+ return model, tokenizer
222
+
223
+
224
+ def get_embeddings(texts: list[str], model, tokenizer=None, batch_size: int = BATCH_SIZE, use_sentence_transformers: bool = False, device: str = DEVICE) -> np.ndarray:
225
+ """
226
+ Получает эмбеддинги для списка текстов с использованием average pooling или sentence_transformers.
227
+
228
+ Args:
229
+ texts: Список текстов
230
+ model: Модель для векторизации или SentenceTransformer
231
+ tokenizer: Токенизатор (None для sentence_transformers)
232
+ batch_size: Размер батча
233
+ use_sentence_transformers: Использовать ли библиотеку sentence_transformers
234
+ device: Устройство для вычислений
235
+
236
+ Returns:
237
+ Массив эмбеддингов
238
+ """
239
+ if use_sentence_transformers:
240
+ # Используем sentence_transformers для получения эмбеддингов
241
+ all_embeddings = []
242
+
243
+ for i in tqdm(range(0, len(texts), batch_size), desc="Векторизация текстов (sentence_transformers)"):
244
+ batch_texts = texts[i:i+batch_size]
245
+
246
+ # Получаем эмбеддинги с помощью sentence_transformers
247
+ embeddings = model.encode(batch_texts, batch_size=batch_size, show_progress_bar=False)
248
+ all_embeddings.append(embeddings)
249
+
250
+ return np.vstack(all_embeddings)
251
+ else:
252
+ # Используем стандартный подход с average pooling
253
+ all_embeddings = []
254
+
255
+ for i in tqdm(range(0, len(texts), batch_size), desc="Векторизация текстов"):
256
+ batch_texts = texts[i:i+batch_size]
257
+
258
+ # Токенизация с обрезкой и padding
259
+ encoding = tokenizer(
260
+ batch_texts,
261
+ padding=True,
262
+ truncation=True,
263
+ max_length=512,
264
+ return_tensors="pt"
265
+ ).to(device)
266
+
267
+ # Получаем эмбеддинги с average pooling
268
+ with torch.no_grad():
269
+ outputs = model(**encoding)
270
+ embeddings = _average_pool(outputs.last_hidden_state, encoding["attention_mask"])
271
+ all_embeddings.append(embeddings.cpu().numpy())
272
+
273
+ return np.vstack(all_embeddings)
274
+
275
+
276
+ def calculate_chunk_overlap(chunk_text: str, punct_text: str) -> float:
277
+ """
278
+ Рассчитывает степень перекрытия между чанком и пунктом с использованием partial_ratio.
279
+
280
+ Args:
281
+ chunk_text: Текст чанка
282
+ punct_text: Текст пункта
283
+
284
+ Returns:
285
+ Коэффициент перекрытия от 0 до 1
286
+ """
287
+ # Если чанк входит в пункт, возвращаем 1.0 (полное вхождение)
288
+ if chunk_text in punct_text:
289
+ return 1.0
290
+
291
+ # Если пункт входит в чанк, возвращаем соотношение длин
292
+ if punct_text in chunk_text:
293
+ return len(punct_text) / len(chunk_text)
294
+
295
+ # Используем partial_ratio из fuzzywuzzy, который лучше обрабатывает
296
+ # случаи, когда один текст является подстрокой другого, даже с небольшими различиями
297
+ partial_ratio_score = fuzz.partial_ratio(chunk_text, punct_text) / 100.0
298
+
299
+ return partial_ratio_score
300
+
301
+
302
+ def save_embeddings_and_data(embeddings: np.ndarray, data: pd.DataFrame, filename: str, output_dir: str):
303
+ """
304
+ Сохраняет эмбеддинги и соответствующие данные в файлы.
305
+
306
+ Args:
307
+ embeddings: Массив эмбеддингов
308
+ data: DataFrame с данными
309
+ filename: Базовое имя файла
310
+ output_dir: Директория для сохранения
311
+ """
312
+ embeddings_path = os.path.join(output_dir, f"{filename}_embeddings.npy")
313
+ data_path = os.path.join(output_dir, f"{filename}_data.csv")
314
+
315
+ # Сохраняем эмбеддинги
316
+ np.save(embeddings_path, embeddings)
317
+ print(f"Эмбеддинги сохранены в {embeddings_path}")
318
+
319
+ # Сохраняем данные
320
+ data.to_csv(data_path, index=False)
321
+ print(f"Данные сохранены в {data_path}")
322
+
323
+
324
+ def load_embeddings_and_data(filename: str, output_dir: str) -> tuple[np.ndarray | None, pd.DataFrame | None]:
325
+ """
326
+ Загружает эмбеддинги и соответствующие данные из файлов.
327
+
328
+ Args:
329
+ filename: Базовое имя файла
330
+ output_dir: Директория, где хранятся файлы
331
+
332
+ Returns:
333
+ Кортеж (эмбеддинги, данные) или (None, None), если файлы не найдены
334
+ """
335
+ embeddings_path = os.path.join(output_dir, f"{filename}_embeddings.npy")
336
+ data_path = os.path.join(output_dir, f"{filename}_data.csv")
337
+
338
+ if os.path.exists(embeddings_path) and os.path.exists(data_path):
339
+ print(f"Загрузка данных из {embeddings_path} и {data_path}...")
340
+ embeddings = np.load(embeddings_path)
341
+ data = pd.read_csv(data_path)
342
+ return embeddings, data
343
+
344
+ return None, None
345
+
346
+
347
+ def save_top_chunks_for_question(
348
+ question_id: int,
349
+ question_text: str,
350
+ question_puncts: list[str],
351
+ top_chunks: pd.DataFrame,
352
+ similarities: dict,
353
+ overlap_data: list,
354
+ output_dir: str
355
+ ):
356
+ """
357
+ Сохраняет топ-чанки для конкретного вопроса в JSON-файл.
358
+
359
+ Args:
360
+ question_id: ID вопроса
361
+ question_text: Текст вопроса
362
+ question_puncts: Список пунктов, относящихся к вопросу
363
+ top_chunks: DataFrame с топ-чанками
364
+ similarities: Словарь с косинусными схожестями для чанков
365
+ overlap_data: Данные о перекрытии чанков с пунктами
366
+ output_dir: Директория для сохранения
367
+ """
368
+ # Подготавливаем результаты для сохранения
369
+ chunks_data = []
370
+
371
+ for i, (idx, chunk) in enumerate(top_chunks.iterrows()):
372
+ # Получаем данные о перекрытии для текущего чанка
373
+ chunk_overlaps = overlap_data[i] if i < len(overlap_data) else []
374
+
375
+ # Преобразуем numpy типы в стандартные типы Python
376
+ similarity = float(similarities.get(idx, 0.0))
377
+
378
+ # Формируем данные чанка
379
+ chunk_data = {
380
+ 'chunk_id': chunk['id'],
381
+ 'doc_name': chunk['doc_name'],
382
+ 'text': chunk['text'],
383
+ 'similarity': similarity,
384
+ 'overlaps': chunk_overlaps
385
+ }
386
+ chunks_data.append(chunk_data)
387
+
388
+ # Преобразуем numpy.int64 в int для question_id
389
+ question_id = int(question_id)
390
+
391
+ # Формируем общий результат
392
+ result = {
393
+ 'question_id': question_id,
394
+ 'question_text': question_text,
395
+ 'puncts': question_puncts,
396
+ 'chunks': chunks_data
397
+ }
398
+
399
+ # Создаем имя файла
400
+ filename = f"question_{question_id}_top_chunks.json"
401
+ filepath = os.path.join(output_dir, filename)
402
+
403
+ # Класс для сериализации numpy типов
404
+ class NumpyEncoder(json.JSONEncoder):
405
+ def default(self, obj):
406
+ if isinstance(obj, np.integer):
407
+ return int(obj)
408
+ if isinstance(obj, np.floating):
409
+ return float(obj)
410
+ if isinstance(obj, np.ndarray):
411
+ return obj.tolist()
412
+ return super().default(obj)
413
+
414
+ # Сохраняем в JSON с кастомным энкодером
415
+ with open(filepath, 'w', encoding='utf-8') as f:
416
+ json.dump(result, f, ensure_ascii=False, indent=2, cls=NumpyEncoder)
417
+
418
+ print(f"Топ-чанки для вопроса {question_id} сохранены в {filepath}")
419
+
420
+
421
+ def evaluate_for_top_n_with_mapping(
422
+ questions_df: pd.DataFrame,
423
+ chunks_df: pd.DataFrame,
424
+ question_embeddings: np.ndarray,
425
+ chunk_embeddings: np.ndarray,
426
+ question_id_to_idx: dict,
427
+ top_n: int,
428
+ similarity_threshold: float,
429
+ top_chunks_dir: str = None
430
+ ) -> tuple[dict[str, float], pd.DataFrame]:
431
+ """
432
+ Оценивает качество чанкинга для заданного значения top_n с использованием маппинга id -> индекс.
433
+
434
+ Args:
435
+ questions_df: DataFrame с вопросами и релевантными пунктами (исходный датасет)
436
+ chunks_df: DataFrame с чанками
437
+ question_embeddings: Эмбеддинги вопросов
438
+ chunk_embeddings: Эмбеддинги чанков
439
+ question_id_to_idx: Словарь соответствия id вопроса и его индекса в массиве эмбеддингов
440
+ top_n: Количество чанков в топе ��ля каждого вопроса
441
+ similarity_threshold: Порог для нечеткого сравнения
442
+ top_chunks_dir: Директория для сохранения топ-чанков (если None, то не сохраняем)
443
+
444
+ Returns:
445
+ Кортеж (словарь с усредненными метриками, DataFrame с метриками по отдельным вопросам)
446
+ """
447
+ print(f"Оценка для top-{top_n}...")
448
+
449
+ # Вычисляем косинусную близость между вопросами и чанками
450
+ similarity_matrix = cosine_similarity(question_embeddings, chunk_embeddings)
451
+
452
+ # Счетчики для метрик на основе текста
453
+ total_puncts = 0
454
+ found_puncts = 0
455
+ total_chunks = 0
456
+ relevant_chunks = 0
457
+
458
+ # Счетчики для метрик на основе документов
459
+ total_docs_required = 0
460
+ found_relevant_docs = 0
461
+ total_docs_found = 0
462
+
463
+ # Для сохранения метрик по отдельным вопросам
464
+ question_metrics = []
465
+
466
+ # Выводим информацию о столбцах для отладки
467
+ print(f"Столбцы в исходном датасете: {questions_df.columns.tolist()}")
468
+
469
+ # Группируем вопросы по id (у нас 20 уникальных вопросов)
470
+ for question_id in tqdm(questions_df['id'].unique(), desc=f"Оценка top-{top_n}"):
471
+ # Получаем строки для текущего вопроса из исходного датасета
472
+ question_rows = questions_df[questions_df['id'] == question_id]
473
+
474
+ # Проверяем, есть ли вопрос с таким id в нашем маппинге
475
+ if question_id not in question_id_to_idx:
476
+ print(f"Предупреждение: вопрос с id {question_id} отсутствует в маппинге")
477
+ continue
478
+
479
+ # Если нет строк с таким id, пропускаем
480
+ if len(question_rows) == 0:
481
+ continue
482
+
483
+ # Получаем индекс вопроса в массиве эмбеддингов
484
+ question_idx = question_id_to_idx[question_id]
485
+
486
+ # Получаем текст вопроса
487
+ question_text = question_rows['question'].iloc[0]
488
+
489
+ # Получаем все пункты для этого вопроса
490
+ puncts = question_rows['text'].tolist()
491
+ question_total_puncts = len(puncts)
492
+ total_puncts += question_total_puncts
493
+
494
+ # Получаем связанные документы
495
+ relevant_docs = []
496
+ if 'filename' in question_rows.columns:
497
+ relevant_docs = [f for f in question_rows['filename'].unique() if f and not pd.isna(f)]
498
+ question_total_docs_required = len(relevant_docs)
499
+ total_docs_required += question_total_docs_required
500
+ print(f"Найдено {question_total_docs_required} документов для вопроса {question_id}")
501
+ else:
502
+ print(f"Столбец 'filename' отсутствует. Используем все документы.")
503
+ relevant_docs = chunks_df['doc_name'].unique().tolist()
504
+ question_total_docs_required = len(relevant_docs)
505
+ total_docs_required += question_total_docs_required
506
+
507
+ # Если для вопроса нет релевантных документов, пропускаем
508
+ if not relevant_docs:
509
+ print(f"Для вопроса {question_id} нет связанных документов")
510
+ continue
511
+
512
+ # Флаги для отслеживания найденных пунктов
513
+ punct_found = [False] * question_total_puncts
514
+
515
+ # Для отслеживания найденных документов
516
+ docs_found_for_question = set()
517
+
518
+ # Для хранения всех чанков вопроса для ограничения top_n
519
+ all_question_chunks = []
520
+ all_question_similarities = []
521
+
522
+ # Собираем чанки для всех документов по этому вопросу
523
+ for filename in relevant_docs:
524
+ if not filename or pd.isna(filename):
525
+ continue
526
+
527
+ # Фильтруем чанки по имени файла
528
+ doc_chunks = chunks_df[chunks_df['doc_name'] == filename]
529
+
530
+ if doc_chunks.empty:
531
+ print(f"Предупреждение: документ {filename} не содержит чанков")
532
+ continue
533
+
534
+ # Индексы чанков для текущего файла
535
+ doc_chunk_indices = doc_chunks.index.tolist()
536
+
537
+ # Получаем значения близости для чанков текущего файла
538
+ doc_similarities = [
539
+ similarity_matrix[question_idx, chunks_df.index.get_loc(idx)]
540
+ for idx in doc_chunk_indices
541
+ ]
542
+
543
+ # Добавляем чанки и их схожести к общему списку для вопроса
544
+ for i, idx in enumerate(doc_chunk_indices):
545
+ all_question_chunks.append((idx, doc_chunks.iloc[doc_chunks.index.get_indexer([idx])[0]]))
546
+ all_question_similarities.append(doc_similarities[i])
547
+
548
+ # Сортируем все чанки по убыванию схожести и берем top_n
549
+ sorted_indices = np.argsort(all_question_similarities)[-min(top_n, len(all_question_similarities)):][::-1]
550
+ top_chunks_indices = [all_question_chunks[i][0] for i in sorted_indices]
551
+ top_chunks = [all_question_chunks[i][1] for i in sorted_indices]
552
+
553
+ # Увеличиваем счетчик общего числа чанков
554
+ question_total_chunks = len(top_chunks)
555
+ total_chunks += question_total_chunks
556
+
557
+ # Для сохранения данных топ-чанков
558
+ all_top_chunks = pd.DataFrame([chunk for chunk in top_chunks])
559
+ all_chunk_similarities = {idx: all_question_similarities[i] for i, idx in enumerate([all_question_chunks[j][0] for j in sorted_indices])}
560
+ all_chunk_overlaps = []
561
+
562
+ # Для каждого чанка проверяем его релевантность к пунктам
563
+ question_relevant_chunks = 0
564
+
565
+ for i, chunk in enumerate(top_chunks):
566
+ is_relevant = False
567
+ chunk_overlaps = []
568
+
569
+ # Добавляем документ в найденные
570
+ docs_found_for_question.add(chunk['doc_name'])
571
+
572
+ # Проверяем перекрытие с каждым пунктом
573
+ for j, punct in enumerate(puncts):
574
+ overlap = calculate_chunk_overlap(chunk['text'], punct)
575
+
576
+ # Если нужно сохранить топ-чанки и top_n == 20
577
+ if top_chunks_dir and top_n == 20:
578
+ chunk_overlaps.append({
579
+ 'punct_index': j,
580
+ 'punct_text': punct[:100] + '...' if len(punct) > 100 else punct,
581
+ 'overlap': overlap
582
+ })
583
+
584
+ # Если перекрытие больше порога, чанк релевантен
585
+ if overlap >= similarity_threshold:
586
+ is_relevant = True
587
+ punct_found[j] = True
588
+
589
+ if is_relevant:
590
+ question_relevant_chunks += 1
591
+
592
+ # Если нужно сохранить топ-чанки и top_n == 20
593
+ if top_chunks_dir and top_n == 20:
594
+ all_chunk_overlaps.append(chunk_overlaps)
595
+
596
+ # Если нужно сохранить топ-чанки и top_n == 20
597
+ if top_chunks_dir and top_n == 20 and not all_top_chunks.empty:
598
+ save_top_chunks_for_question(
599
+ question_id,
600
+ question_text,
601
+ puncts,
602
+ all_top_chunks,
603
+ all_chunk_similarities,
604
+ all_chunk_overlaps,
605
+ top_chunks_dir
606
+ )
607
+
608
+ # Подсчитываем метрики для текущего вопроса
609
+ question_found_puncts = sum(punct_found)
610
+ found_puncts += question_found_puncts
611
+
612
+ relevant_chunks += question_relevant_chunks
613
+
614
+ # Обновляем метрики для документов
615
+ question_found_relevant_docs = sum(1 for doc in docs_found_for_question if doc in relevant_docs)
616
+ found_relevant_docs += question_found_relevant_docs
617
+ question_total_docs_found = len(docs_found_for_question)
618
+ total_docs_found += question_total_docs_found
619
+
620
+ # Вычисляем метрики для текущего вопроса
621
+ question_text_precision = question_relevant_chunks / question_total_chunks if question_total_chunks > 0 else 0
622
+ question_text_recall = question_found_puncts / question_total_puncts if question_total_puncts > 0 else 0
623
+ question_text_f1 = 2 * question_text_precision * question_text_recall / (question_text_precision + question_text_recall) if question_text_precision + question_text_recall > 0 else 0
624
+
625
+ question_doc_precision = question_found_relevant_docs / question_total_docs_found if question_total_docs_found > 0 else 0
626
+ question_doc_recall = question_found_relevant_docs / question_total_docs_required if question_total_docs_required > 0 else 0
627
+ question_doc_f1 = 2 * question_doc_precision * question_doc_recall / (question_doc_precision + question_doc_recall) if question_doc_precision + question_doc_recall > 0 else 0
628
+
629
+ # Сохраняем метрики вопроса
630
+ question_metrics.append({
631
+ 'question_id': question_id,
632
+ 'question_text': question_text,
633
+ 'top_n': top_n,
634
+ 'text_precision': question_text_precision,
635
+ 'text_recall': question_text_recall,
636
+ 'text_f1': question_text_f1,
637
+ 'doc_precision': question_doc_precision,
638
+ 'doc_recall': question_doc_recall,
639
+ 'doc_f1': question_doc_f1,
640
+ 'found_puncts': question_found_puncts,
641
+ 'total_puncts': question_total_puncts,
642
+ 'relevant_chunks': question_relevant_chunks,
643
+ 'total_chunks': question_total_chunks,
644
+ 'found_relevant_docs': question_found_relevant_docs,
645
+ 'total_docs_required': question_total_docs_required,
646
+ 'total_docs_found': question_total_docs_found
647
+ })
648
+
649
+ # Вычисляем метрики для текста
650
+ text_precision = relevant_chunks / total_chunks if total_chunks > 0 else 0
651
+ text_recall = found_puncts / total_puncts if total_puncts > 0 else 0
652
+ text_f1 = 2 * text_precision * text_recall / (text_precision + text_recall) if text_precision + text_recall > 0 else 0
653
+
654
+ # Вычисляем метрики для документов
655
+ doc_precision = found_relevant_docs / total_docs_found if total_docs_found > 0 else 0
656
+ doc_recall = found_relevant_docs / total_docs_required if total_docs_required > 0 else 0
657
+ doc_f1 = 2 * doc_precision * doc_recall / (doc_precision + doc_recall) if doc_precision + doc_recall > 0 else 0
658
+
659
+ aggregated_metrics = {
660
+ 'top_n': top_n,
661
+ 'text_precision': text_precision,
662
+ 'text_recall': text_recall,
663
+ 'text_f1': text_f1,
664
+ 'doc_precision': doc_precision,
665
+ 'doc_recall': doc_recall,
666
+ 'doc_f1': doc_f1,
667
+ 'found_puncts': found_puncts,
668
+ 'total_puncts': total_puncts,
669
+ 'relevant_chunks': relevant_chunks,
670
+ 'total_chunks': total_chunks,
671
+ 'found_relevant_docs': found_relevant_docs,
672
+ 'total_docs_required': total_docs_required,
673
+ 'total_docs_found': total_docs_found
674
+ }
675
+
676
+ return aggregated_metrics, pd.DataFrame(question_metrics)
677
+
678
+
679
+ def main():
680
+ """
681
+ Основная функция скрипта.
682
+ """
683
+ args = parse_args()
684
+
685
+ # Устанавливаем устройство из аргументов
686
+ device = args.device
687
+
688
+ # Создаем выходной каталог, если его нет
689
+ os.makedirs(args.output_dir, exist_ok=True)
690
+
691
+ # Создаем директорию для топ-чанков
692
+ top_chunks_dir = os.path.join(args.output_dir, "top_chunks")
693
+ os.makedirs(top_chunks_dir, exist_ok=True)
694
+
695
+ # Загружаем датасет с вопросами
696
+ questions_df = load_questions_dataset(args.dataset_path)
697
+
698
+ # Формируем уникальное имя для сохраняемых файлов на основе параметров стратегии и модели
699
+ strategy_config_str = f"fixed_size_w{args.words_per_chunk}_o{args.overlap_words}"
700
+ chunks_filename = f"chunks_{strategy_config_str}_{args.model_name.replace('/', '_')}"
701
+ questions_filename = f"questions_{args.model_name.replace('/', '_')}"
702
+
703
+ # Пытаемся загрузить сохраненные эмбеддинги и данные
704
+ chunk_embeddings, chunks_df = None, None
705
+ question_embeddings, questions_df_with_embeddings = None, None
706
+
707
+ if not args.force_recompute:
708
+ chunk_embeddings, chunks_df = load_embeddings_and_data(chunks_filename, args.output_dir)
709
+ question_embeddings, questions_df_with_embeddings = load_embeddings_and_data(questions_filename, args.output_dir)
710
+
711
+ # Если не удалось загрузить данные или включен режим принудительного пересчета
712
+ if chunk_embeddings is None or chunks_df is None:
713
+ # Читаем и обрабатываем документы
714
+ documents = read_documents(args.data_folder)
715
+
716
+ # Формируем конфигурацию для стратегии fixed_size
717
+ fixed_size_config = {
718
+ "words_per_chunk": args.words_per_chunk,
719
+ "overlap_words": args.overlap_words
720
+ }
721
+
722
+ # Получаем DataFrame с чанками
723
+ chunks_df = process_documents(documents, fixed_size_config)
724
+
725
+ # Настраиваем модель и токенизатор
726
+ model, tokenizer = setup_model_and_tokenizer(args.model_name, args.use_sentence_transformers, device)
727
+
728
+ # Получаем эмбеддинги для чанков
729
+ chunk_embeddings = get_embeddings(chunks_df['text'].tolist(), model, tokenizer, args.batch_size, args.use_sentence_transformers, device)
730
+
731
+ # Сохраняем эмбеддинги и данные
732
+ save_embeddings_and_data(chunk_embeddings, chunks_df, chunks_filename, args.output_dir)
733
+
734
+ # Если не удалось загрузить эмбеддинги вопросов или включен режим принудительного пересчета
735
+ if question_embeddings is None or questions_df_with_embeddings is None:
736
+ # Получаем уникальные вопросы (по id)
737
+ unique_questions = questions_df.drop_duplicates(subset=['id'])[['id', 'question']]
738
+
739
+ # Настраиваем модель и токенизатор (если еще не настроены)
740
+ if 'model' not in locals() or 'tokenizer' not in locals():
741
+ model, tokenizer = setup_model_and_tokenizer(args.model_name, args.use_sentence_transformers, device)
742
+
743
+ # Получаем эмбеддинги для вопросов
744
+ question_embeddings = get_embeddings(unique_questions['question'].tolist(), model, tokenizer, args.batch_size, args.use_sentence_transformers, device)
745
+
746
+ # Сохраняем эмбеддинги и данные
747
+ save_embeddings_and_data(question_embeddings, unique_questions, questions_filename, args.output_dir)
748
+
749
+ # Устанавливаем questions_df_with_embeddings для дальнейшего использования
750
+ questions_df_with_embeddings = unique_questions
751
+
752
+ # Создаем словарь соответствия id вопроса и его индекса в эмбеддингах
753
+ question_id_to_idx = {
754
+ row['id']: i
755
+ for i, (_, row) in enumerate(questions_df_with_embeddings.iterrows())
756
+ }
757
+
758
+ # Оцениваем стратегию чанкинга для разных значений top_n
759
+ aggregated_results = []
760
+ all_question_metrics = []
761
+
762
+ for top_n in TOP_N_VALUES:
763
+ metrics, question_metrics = evaluate_for_top_n_with_mapping(
764
+ questions_df, # Исходный датасет с связью между вопросами и документами
765
+ chunks_df, # Датасет с чанками
766
+ question_embeddings, # Эмбеддинги вопросов
767
+ chunk_embeddings, # Эмбеддинги чанков
768
+ question_id_to_idx, # Маппинг id вопроса к индексу в эмбеддингах
769
+ top_n, # Количество чанков в топе
770
+ args.similarity_threshold, # Порог для определения перекрытия
771
+ top_chunks_dir if top_n == 20 else None # Сохраняем топ-чанки только для top_n=20
772
+ )
773
+ aggregated_results.append(metrics)
774
+ all_question_metrics.append(question_metrics)
775
+
776
+ # Объединяем все метрики по вопросам
777
+ all_question_metrics_df = pd.concat(all_question_metrics)
778
+
779
+ # Создаем DataFrame с агрегированными результатами
780
+ aggregated_results_df = pd.DataFrame(aggregated_results)
781
+
782
+ # Сохраняем результаты
783
+ results_filename = f"results_{strategy_config_str}_{args.model_name.replace('/', '_')}.csv"
784
+ results_path = os.path.join(args.output_dir, results_filename)
785
+ aggregated_results_df.to_csv(results_path, index=False)
786
+
787
+ # Сохраняем метрики по вопросам
788
+ question_metrics_filename = f"question_metrics_{strategy_config_str}_{args.model_name.replace('/', '_')}.xlsx"
789
+ question_metrics_path = os.path.join(args.output_dir, question_metrics_filename)
790
+ all_question_metrics_df.to_excel(question_metrics_path, index=False)
791
+
792
+ print(f"\nРезультаты сохранены в {results_path}")
793
+ print(f"Метрики по вопросам сохранены в {question_metrics_path}")
794
+ print(f"Топ-20 чанков для каждого вопроса сохранены в {top_chunks_dir}")
795
+ print("\nМетрики для различных значений top_n:")
796
+ print(aggregated_results_df[['top_n', 'text_precision', 'text_recall', 'text_f1', 'doc_precision', 'doc_recall', 'doc_f1']])
797
+
798
+
799
+ if __name__ == "__main__":
800
+ main()
lib/extractor/scripts/plot_macro_metrics.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для построения специализированных графиков на основе макрометрик из Excel-файла.
4
+ Строит несколько типов графиков:
5
+ 1. Зависимость macro_text_recall от top_N для разных моделей при фиксированных параметрах чанкинга
6
+ 2. Зависимость macro_text_recall от top_N для разных подходов к чанкингу при фиксированных моделях
7
+ 3. Зависимость macro_text_recall от подхода к чанкингу для разных моделей при фиксированных top_N
8
+ """
9
+
10
+ import os
11
+
12
+ import matplotlib.pyplot as plt
13
+ import pandas as pd
14
+ import seaborn as sns
15
+
16
+ # Константы
17
+ EXCEL_FILE_PATH = "../../Белагропромбанк/test_vectors/combined_results.xlsx"
18
+ PLOTS_DIR = "../../Белагропромбанк/test_vectors/plots"
19
+
20
+ # Настройки для графиков
21
+ plt.rcParams['font.family'] = 'DejaVu Sans'
22
+ sns.set_style("whitegrid")
23
+ FIGSIZE = (14, 10)
24
+ DPI = 300
25
+
26
+
27
+ def setup_plots_directory(plots_dir: str) -> None:
28
+ """
29
+ Создает директорию для сохранения графиков, если она не существует.
30
+
31
+ Args:
32
+ plots_dir: Путь к директории для графиков
33
+ """
34
+ if not os.path.exists(plots_dir):
35
+ os.makedirs(plots_dir)
36
+ print(f"Создана директория для графиков: {plots_dir}")
37
+ else:
38
+ print(f"Использование существующей директории для графиков: {plots_dir}")
39
+
40
+
41
+ def load_macro_metrics(excel_path: str) -> pd.DataFrame:
42
+ """
43
+ Загружает макрометрики из Excel-файла.
44
+
45
+ Args:
46
+ excel_path: Путь к Excel-файлу с данными
47
+
48
+ Returns:
49
+ DataFrame с макрометриками
50
+ """
51
+ try:
52
+ df = pd.read_excel(excel_path, sheet_name="Macro метрики")
53
+ print(f"Загружены данные из {excel_path}, лист 'Macro метрики'")
54
+ print(f"Количество строк: {len(df)}")
55
+ return df
56
+ except Exception as e:
57
+ print(f"Ошибка при загрузке данных: {e}")
58
+ raise
59
+
60
+
61
+ def plot_top_n_vs_recall_by_model(df: pd.DataFrame, plots_dir: str) -> None:
62
+ """
63
+ Строит графики зависимости macro_text_recall от top_N для разных моделей
64
+ при фиксированных параметрах чанкинга (50/25 и 200/75).
65
+
66
+ Args:
67
+ df: DataFrame с данными
68
+ plots_dir: Директория для сохранения графиков
69
+ """
70
+ # Фиксированные параметры чанкинга
71
+ chunking_params = [
72
+ {"words": 50, "overlap": 25, "title": "Чанкинг 50/25"},
73
+ {"words": 200, "overlap": 75, "title": "Чанкинг 200/75"}
74
+ ]
75
+
76
+ # Создаем субплоты: 1 строка, 2 столбца
77
+ fig, axes = plt.subplots(1, 2, figsize=FIGSIZE, sharey=True)
78
+
79
+ for i, params in enumerate(chunking_params):
80
+ # Фильтруем данные для текущих параметров чанкинга
81
+ filtered_df = df[
82
+ (df['words_per_chunk'] == params['words']) &
83
+ (df['overlap_words'] == params['overlap'])
84
+ ]
85
+
86
+ if len(filtered_df) == 0:
87
+ print(f"Предупреждение: нет данных для чанкинга {params['words']}/{params['overlap']}")
88
+ axes[i].text(0.5, 0.5, f"Нет данных для чанкинга {params['words']}/{params['overlap']}",
89
+ ha='center', va='center', fontsize=12)
90
+ axes[i].set_title(params['title'])
91
+ continue
92
+
93
+ # Находим уникальные модели
94
+ models = filtered_df['model'].unique()
95
+
96
+ # Создаем палитру цветов
97
+ palette = sns.color_palette("viridis", len(models))
98
+
99
+ # Строим график для каждой модели
100
+ for j, model in enumerate(models):
101
+ model_df = filtered_df[filtered_df['model'] == model].sort_values('top_n')
102
+
103
+ if len(model_df) <= 1:
104
+ print(f"Предупреждение: недостаточно данных для модели {model} при чанкинге {params['words']}/{params['overlap']}")
105
+ continue
106
+
107
+ # Строим ломаную линию
108
+ axes[i].plot(model_df['top_n'], model_df['macro_text_recall'],
109
+ marker='o', linestyle='-', linewidth=2,
110
+ label=model, color=palette[j])
111
+
112
+ # Настраиваем оси и заголовок
113
+ axes[i].set_title(params['title'], fontsize=14)
114
+ axes[i].set_xlabel('top_N', fontsize=12)
115
+ if i == 0:
116
+ axes[i].set_ylabel('macro_text_recall', fontsize=12)
117
+
118
+ # Добавляем сетку
119
+ axes[i].grid(True, linestyle='--', alpha=0.7)
120
+
121
+ # Добавляем легенду
122
+ axes[i].legend(title="Модель", fontsize=10, loc='best')
123
+
124
+ # Общий заголовок
125
+ plt.suptitle('Зависимость macro_text_recall от top_N для разных моделей', fontsize=16)
126
+
127
+ # Настраиваем макет
128
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
129
+
130
+ # Сохраняем график
131
+ file_path = os.path.join(plots_dir, "top_n_vs_recall_by_model.png")
132
+ plt.savefig(file_path, dpi=DPI)
133
+ plt.close()
134
+
135
+ print(f"Создан график: {file_path}")
136
+
137
+
138
+ def plot_top_n_vs_recall_by_chunking(df: pd.DataFrame, plots_dir: str) -> None:
139
+ """
140
+ Строит графики зависимости macro_text_recall от top_N для разных параметров чанкинга
141
+ при фиксированных моделях (bge и frida).
142
+
143
+ Args:
144
+ df: DataFrame с данными
145
+ plots_dir: Директория для сохранения графиков
146
+ """
147
+ # Фиксированные модели
148
+ models = ["BAAI/bge", "frida"]
149
+
150
+ # Создаем субплоты: 1 строка, 2 столбца
151
+ fig, axes = plt.subplots(1, 2, figsize=FIGSIZE, sharey=True)
152
+
153
+ for i, model_name in enumerate(models):
154
+ # Находим все строки с моделями, содержащими указанное название
155
+ model_df = df[df['model'].str.contains(model_name, case=False)]
156
+
157
+ if len(model_df) == 0:
158
+ print(f"Предупреждение: нет данных для модели {model_name}")
159
+ axes[i].text(0.5, 0.5, f"Нет данных для модели {model_name}",
160
+ ha='center', va='center', fontsize=12)
161
+ axes[i].set_title(f"Модель: {model_name}")
162
+ continue
163
+
164
+ # Находим уникальные комбинации параметров чанкинга
165
+ chunking_combinations = model_df.drop_duplicates(['words_per_chunk', 'overlap_words'])[['words_per_chunk', 'overlap_words']]
166
+
167
+ # Ограничиваем количество комбинаций до 7 для читаемости
168
+ if len(chunking_combinations) > 7:
169
+ print(f"Предупреждение: слишком много комбинаций чанкинга для модели {model_name}, ограничиваем до 7")
170
+ chunking_combinations = chunking_combinations.head(7)
171
+
172
+ # Создаем палитру цветов
173
+ palette = sns.color_palette("viridis", len(chunking_combinations))
174
+
175
+ # Строим график для каждой комбинации параметров чанкинга
176
+ for j, (_, row) in enumerate(chunking_combinations.iterrows()):
177
+ words = row['words_per_chunk']
178
+ overlap = row['overlap_words']
179
+
180
+ # Фильтруем данные для текущей модели и параметров чанкинга
181
+ chunking_df = model_df[
182
+ (model_df['words_per_chunk'] == words) &
183
+ (model_df['overlap_words'] == overlap)
184
+ ].sort_values('top_n')
185
+
186
+ if len(chunking_df) <= 1:
187
+ print(f"Предупреждение: недостаточно данных для модели {model_name} с чанкингом {words}/{overlap}")
188
+ continue
189
+
190
+ # Строим ломаную линию
191
+ axes[i].plot(chunking_df['top_n'], chunking_df['macro_text_recall'],
192
+ marker='o', linestyle='-', linewidth=2,
193
+ label=f"w={words}, o={overlap}", color=palette[j])
194
+
195
+ # Настраиваем оси и заголовок
196
+ axes[i].set_title(f"Модель: {model_name}", fontsize=14)
197
+ axes[i].set_xlabel('top_N', fontsize=12)
198
+ if i == 0:
199
+ axes[i].set_ylabel('macro_text_recall', fontsize=12)
200
+
201
+ # Добавляем сетку
202
+ axes[i].grid(True, linestyle='--', alpha=0.7)
203
+
204
+ # Добавляем легенду
205
+ axes[i].legend(title="Чанкинг", fontsize=10, loc='best')
206
+
207
+ # Общий заголовок
208
+ plt.suptitle('Зависимость macro_text_recall от top_N для разных параметров чанкинга', fontsize=16)
209
+
210
+ # Настраиваем макет
211
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
212
+
213
+ # Сохраняем график
214
+ file_path = os.path.join(plots_dir, "top_n_vs_recall_by_chunking.png")
215
+ plt.savefig(file_path, dpi=DPI)
216
+ plt.close()
217
+
218
+ print(f"Создан график: {file_path}")
219
+
220
+
221
+ def plot_chunking_vs_recall_by_model(df: pd.DataFrame, plots_dir: str) -> None:
222
+ """
223
+ Строит графики зависимости macro_text_recall от подхода к чанкингу
224
+ для разных моделей при фиксированных top_N (5, 20, 100).
225
+
226
+ Args:
227
+ df: DataFrame с данными
228
+ plots_dir: Директория для сохранения графиков
229
+ """
230
+ # Фиксированные значения top_N
231
+ top_n_values = [5, 20, 100]
232
+
233
+ # Создаем субплоты: 1 строка, 3 столбца
234
+ fig, axes = plt.subplots(1, 3, figsize=FIGSIZE, sharey=True)
235
+
236
+ # Создаем порядок чанкинга - сортируем по возрастанию размера и оверлапа
237
+ chunking_order = df.drop_duplicates(['words_per_chunk', 'overlap_words'])[['words_per_chunk', 'overlap_words']]
238
+ chunking_order = chunking_order.sort_values(['words_per_chunk', 'overlap_words'])
239
+
240
+ # Создаем словарь для маппинга комбинаций чанкинга на индексы
241
+ chunking_labels = [f"{row['words_per_chunk']}/{row['overlap_words']}" for _, row in chunking_order.iterrows()]
242
+ chunking_map = {f"{row['words_per_chunk']}/{row['overlap_words']}": i for i, (_, row) in enumerate(chunking_order.iterrows())}
243
+
244
+ for i, top_n in enumerate(top_n_values):
245
+ # Фильтруем данные для текущего top_N
246
+ top_n_df = df[df['top_n'] == top_n]
247
+
248
+ if len(top_n_df) == 0:
249
+ print(f"Предупреждение: нет данных для top_N={top_n}")
250
+ axes[i].text(0.5, 0.5, f"Нет данных для top_N={top_n}",
251
+ ha='center', va='center', fontsize=12)
252
+ axes[i].set_title(f"top_N={top_n}")
253
+ continue
254
+
255
+ # Находим уникальные модели
256
+ models = top_n_df['model'].unique()
257
+
258
+ # Ограничиваем количество моделей до 5 для читаемости
259
+ if len(models) > 5:
260
+ print(f"Предупреждение: слишком много моделей для top_N={top_n}, ограничиваем до 5")
261
+ models = models[:5]
262
+
263
+ # Создаем палитру цветов
264
+ palette = sns.color_palette("viridis", len(models))
265
+
266
+ # Строим график для каждой модели
267
+ for j, model in enumerate(models):
268
+ model_df = top_n_df[top_n_df['model'] == model].copy()
269
+
270
+ if len(model_df) <= 1:
271
+ print(f"Предупреждение: недостаточно данных для модели {model} при top_N={top_n}")
272
+ continue
273
+
274
+ # Создаем новую колонку с индексом чанкинга для сортировки
275
+ model_df['chunking_index'] = model_df.apply(
276
+ lambda row: chunking_map.get(f"{row['words_per_chunk']}/{row['overlap_words']}", -1),
277
+ axis=1
278
+ )
279
+
280
+ # Отбрасываем строки с неизвестными комбинациями чанкинга
281
+ model_df = model_df[model_df['chunking_index'] >= 0]
282
+
283
+ if len(model_df) <= 1:
284
+ continue
285
+
286
+ # Сортируем по индексу чанкинга
287
+ model_df = model_df.sort_values('chunking_index')
288
+
289
+ # Создаем список индексов и значений для графика
290
+ x_indices = model_df['chunking_index'].tolist()
291
+ y_values = model_df['macro_text_recall'].tolist()
292
+
293
+ # Строим ломаную линию
294
+ axes[i].plot(x_indices, y_values, marker='o', linestyle='-', linewidth=2,
295
+ label=model, color=palette[j])
296
+
297
+ # Настраиваем оси и заголовок
298
+ axes[i].set_title(f"top_N={top_n}", fontsize=14)
299
+ axes[i].set_xlabel('Подход к чанкингу', fontsize=12)
300
+ if i == 0:
301
+ axes[i].set_ylabel('macro_text_recall', fontsize=12)
302
+
303
+ # Устанавливаем метки на оси X (подходы к чанкингу)
304
+ axes[i].set_xticks(range(len(chunking_labels)))
305
+ axes[i].set_xticklabels(chunking_labels, rotation=45, ha='right', fontsize=10)
306
+
307
+ # Добавляем сетку
308
+ axes[i].grid(True, linestyle='--', alpha=0.7)
309
+
310
+ # Добавляем легенду
311
+ axes[i].legend(title="Модель", fontsize=10, loc='best')
312
+
313
+ # Общий заголовок
314
+ plt.suptitle('Зависимость macro_text_recall от подхода к чанкингу для разных моделей', fontsize=16)
315
+
316
+ # Настраиваем макет
317
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
318
+
319
+ # Сохраняем график
320
+ file_path = os.path.join(plots_dir, "chunking_vs_recall_by_model.png")
321
+ plt.savefig(file_path, dpi=DPI)
322
+ plt.close()
323
+
324
+ print(f"Создан график: {file_path}")
325
+
326
+
327
+ def main():
328
+ """Основная функция скрипта."""
329
+ # Создаем директорию для графиков
330
+ setup_plots_directory(PLOTS_DIR)
331
+
332
+ # Загружаем данные
333
+ try:
334
+ macro_metrics = load_macro_metrics(EXCEL_FILE_PATH)
335
+ except Exception as e:
336
+ print(f"Критическая ошибка: {e}")
337
+ return
338
+
339
+ # Строим графики
340
+ plot_top_n_vs_recall_by_model(macro_metrics, PLOTS_DIR)
341
+ plot_top_n_vs_recall_by_chunking(macro_metrics, PLOTS_DIR)
342
+ plot_chunking_vs_recall_by_model(macro_metrics, PLOTS_DIR)
343
+
344
+ print("Готово! Все графики созданы.")
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()
lib/extractor/scripts/prepare_dataset.py ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для подготовки датасета с вопросами и текстами пунктов/приложений.
4
+ Преобразует исходный датасет, содержащий списки пунктов, в расширенный датасет,
5
+ где каждому пункту/приложению соответствует отдельная строка.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, Dict
12
+
13
+ import pandas as pd
14
+ from tqdm import tqdm
15
+
16
+ from ntr_text_fragmentation import Destructurer
17
+
18
+ sys.path.insert(0, str(Path(__file__).parent.parent))
19
+ from ntr_fileparser import UniversalParser
20
+
21
+
22
+ def parse_args():
23
+ """
24
+ Парсит аргументы командной строки.
25
+
26
+ Returns:
27
+ Аргументы командной строки
28
+ """
29
+ parser = argparse.ArgumentParser(description="Подготовка датасета с текстами пунктов")
30
+
31
+ parser.add_argument('--input-dataset', type=str, default='data/dataset.xlsx',
32
+ help='Путь к исходному датасету (Excel-файл)')
33
+ parser.add_argument('--output-dataset', type=str, default='data/dataset_with_texts.xlsx',
34
+ help='Путь для сохранения подготовленного датасета (Excel-файл)')
35
+ parser.add_argument('--data-folder', type=str, default='data/docs',
36
+ help='Путь к папке с документами')
37
+ parser.add_argument('--debug', action='store_true',
38
+ help='Включить режим отладки с дополнительным выводом информации')
39
+
40
+ return parser.parse_args()
41
+
42
+
43
+ def load_dataset(file_path: str, debug: bool = False) -> pd.DataFrame:
44
+ """
45
+ Загружает исходный датасет с вопросами.
46
+
47
+ Args:
48
+ file_path: Путь к Excel-файлу
49
+ debug: Режим отладки
50
+
51
+ Returns:
52
+ DataFrame с вопросами
53
+ """
54
+ print(f"Загрузка исходного датасета из {file_path}...")
55
+
56
+ df = pd.read_excel(file_path)
57
+
58
+ # Преобразуем строковые списки в настоящие списки
59
+ for col in ['puncts', 'appendices']:
60
+ if col in df.columns:
61
+ df[col] = df[col].apply(lambda x:
62
+ eval(x) if isinstance(x, str) and x.strip()
63
+ else ([] if pd.isna(x) else x))
64
+
65
+ # Вывод отладочной информации о форматах пунктов/приложений
66
+ if debug:
67
+ all_puncts = set()
68
+ all_appendices = set()
69
+
70
+ for _, row in df.iterrows():
71
+ if 'puncts' in row and row['puncts']:
72
+ all_puncts.update(row['puncts'])
73
+ if 'appendices' in row and row['appendices']:
74
+ all_appendices.update(row['appendices'])
75
+
76
+ print(f"\nУникальные форматы пунктов в датасете ({len(all_puncts)}):")
77
+ for i, p in enumerate(sorted(all_puncts)):
78
+ if i < 20 or i > len(all_puncts) - 20:
79
+ print(f" - {repr(p)}")
80
+ elif i == 20:
81
+ print(" ... (пропущено)")
82
+
83
+ print(f"\nУникальные форматы приложений в датасете ({len(all_appendices)}):")
84
+ for app in sorted(all_appendices):
85
+ print(f" - {repr(app)}")
86
+
87
+ print(f"Загружено {len(df)} вопросов")
88
+ return df
89
+
90
+
91
+ def read_documents(folder_path: str) -> Dict[str, Any]:
92
+ """
93
+ Читает все документы из указанной папки.
94
+
95
+ Args:
96
+ folder_path: Путь к папке с документами
97
+
98
+ Returns:
99
+ Словарь {имя_файла: parsed_document}
100
+ """
101
+ print(f"Чтение документов из {folder_path}...")
102
+ parser = UniversalParser()
103
+ documents = {}
104
+
105
+ for file_path in tqdm(list(Path(folder_path).glob("*.docx")), desc="Чтение документов"):
106
+ try:
107
+ doc_name = file_path.stem
108
+ documents[doc_name] = parser.parse_by_path(str(file_path))
109
+ except Exception as e:
110
+ print(f"Ошибка при чтении файла {file_path}: {e}")
111
+
112
+ print(f"Прочитано {len(documents)} документов")
113
+ return documents
114
+
115
+
116
+ def normalize_punct_format(punct: str) -> str:
117
+ """
118
+ Нормализует формат номера пункта для единообразного сравнения.
119
+
120
+ Args:
121
+ punct: Номер пункта
122
+
123
+ Returns:
124
+ Нормализованный номер пункта
125
+ """
126
+ # Убираем пробелы
127
+ punct = punct.strip()
128
+
129
+ # Убираем завершающую точку, если она есть
130
+ if punct.endswith('.'):
131
+ punct = punct[:-1]
132
+
133
+ return punct
134
+
135
+
136
+ def normalize_appendix_format(appendix: str) -> str:
137
+ """
138
+ Нормализует формат номера приложения для единообразного сравнения.
139
+
140
+ Args:
141
+ appendix: Номер приложения
142
+
143
+ Returns:
144
+ Нормализованный номер приложения
145
+ """
146
+ # Убираем пробелы
147
+ appendix = appendix.strip()
148
+
149
+ # Обработка форматов с дефисом (например, "14-1")
150
+ if "-" in appendix:
151
+ return appendix
152
+
153
+ return appendix
154
+
155
+
156
+ def find_matching_key(search_key, available_keys, item_type='punct', debug_mode=False):
157
+ """
158
+ Ищет наиболее подходящий ключ среди доступных ключей с учетом типа элемента
159
+
160
+ Args:
161
+ search_key: Ключ для поиска
162
+ available_keys: Доступные ключи
163
+ item_type: Тип элемента ('punct' или 'appendix')
164
+ debug_mode: Режим отладки
165
+
166
+ Returns:
167
+ Найденный ключ или None
168
+ """
169
+ if not available_keys:
170
+ return None
171
+
172
+ # Нормализуем ключ в зависимости от типа элемента
173
+ if item_type == 'punct':
174
+ normalized_search_key = normalize_punct_format(search_key)
175
+ else: # appendix
176
+ normalized_search_key = normalize_appendix_format(search_key)
177
+
178
+ # Проверяем прямое совпадение ключей
179
+ for key in available_keys:
180
+ if item_type == 'punct':
181
+ normalized_key = normalize_punct_format(key)
182
+ else: # appendix
183
+ normalized_key = normalize_appendix_format(key)
184
+
185
+ if normalized_key == normalized_search_key:
186
+ if debug_mode:
187
+ print(f"Найдено прямое совпадение для {item_type} {search_key} -> {key}")
188
+ return key
189
+
190
+ # Если прямого совпадения нет, проверяем "мягкое" совпадение
191
+ # Только для пунктов, не для приложений
192
+ if item_type == 'punct':
193
+ for key in available_keys:
194
+ normalized_key = normalize_punct_format(key)
195
+
196
+ # Если ключ содержит "/", это подпункт приложения, его не следует сопоставлять с обычным пунктом
197
+ if '/' in key and '/' not in search_key:
198
+ continue
199
+
200
+ # Проверяем совпадение конца номера (например, "1.2" и "1.2.")
201
+ if normalized_key.rstrip('.') == normalized_search_key.rstrip('.'):
202
+ if debug_mode:
203
+ print(f"Найдено мягкое совпадение для {search_key} -> {key}")
204
+ return key
205
+
206
+ return None
207
+
208
+
209
+ def extract_item_texts(documents, debug_mode=False):
210
+ """
211
+ Извлекает тексты пунктов и приложений из документов.
212
+
213
+ Args:
214
+ documents: Словарь с распарсенными документами {doc_name: document}
215
+ debug_mode: Включать ли режим отладки
216
+
217
+ Returns:
218
+ Словарь с текстами пунктов и приложений, организованный по названиям документов
219
+ """
220
+ print("Извлечение текстов пунктов и приложений...")
221
+
222
+ item_texts = {}
223
+ all_extracted_items = set()
224
+ all_extracted_appendices = set()
225
+
226
+ for doc_name, document in tqdm(documents.items(), desc="Применение стратегии numbered_items"):
227
+ # Используем стратегию numbered_items с режимом отладки
228
+ destructurer = Destructurer(document)
229
+ destructurer.configure('numbered_items', debug_mode=debug_mode)
230
+ entities, _ = destructurer.destructure()
231
+
232
+ # Инициализируем структуру для документа, если она еще не создана
233
+ if doc_name not in item_texts:
234
+ item_texts[doc_name] = {
235
+ 'puncts': {}, # Для пунктов основного текста
236
+ 'appendices': {} # Для приложений
237
+ }
238
+
239
+ for entity in entities:
240
+ # Пропускаем сущность документа
241
+ if entity.type == "Document":
242
+ continue
243
+
244
+ # Работаем только с чанками для поиска
245
+ if hasattr(entity, 'use_in_search') and entity.use_in_search:
246
+ metadata = entity.metadata
247
+ text = entity.text
248
+
249
+ # Для пунктов
250
+ if 'item_number' in metadata:
251
+ item_number = metadata['item_number']
252
+
253
+ # Проверяем, является ли пункт подпунктом приложения
254
+ if 'appendix_number' in metadata:
255
+ # Это подпункт приложения
256
+ appendix_number = metadata['appendix_number']
257
+
258
+ # Создаем структуру для приложения, если ее еще нет
259
+ if appendix_number not in item_texts[doc_name]['appendices']:
260
+ item_texts[doc_name]['appendices'][appendix_number] = {
261
+ 'main_text': '', # Основной текст приложения
262
+ 'subpuncts': {} # Подпункты приложения
263
+ }
264
+
265
+ # Добавляем подпункт в словарь подпунктов
266
+ item_texts[doc_name]['appendices'][appendix_number]['subpuncts'][item_number] = text
267
+
268
+ if debug_mode:
269
+ print(f"Извлечен подпункт {item_number} приложения {appendix_number} из {doc_name}")
270
+
271
+ all_extracted_items.add(item_number)
272
+ else:
273
+ # Обычный пункт
274
+ item_texts[doc_name]['puncts'][item_number] = text
275
+
276
+ if debug_mode:
277
+ print(f"Извлечен пункт {item_number} из {doc_name}")
278
+
279
+ all_extracted_items.add(item_number)
280
+
281
+ # Для приложений
282
+ elif 'appendix_number' in metadata and 'item_number' not in metadata:
283
+ appendix_number = metadata['appendix_number']
284
+
285
+ # Создаем структуру для приложения, если ее еще нет
286
+ if appendix_number not in item_texts[doc_name]['appendices']:
287
+ item_texts[doc_name]['appendices'][appendix_number] = {
288
+ 'main_text': text, # Основной текст приложения
289
+ 'subpuncts': {} # Подпункты приложения
290
+ }
291
+ else:
292
+ # Если приложение уже существует, обновляем основной текст
293
+ item_texts[doc_name]['appendices'][appendix_number]['main_text'] = text
294
+
295
+ if debug_mode:
296
+ print(f"Извлечено приложение {appendix_number} из {doc_name}")
297
+
298
+ all_extracted_appendices.add(appendix_number)
299
+
300
+ # Выводим статистику, если включен режим отладки
301
+ if debug_mode:
302
+ print(f"\nВсего извлечено уникальных пунктов: {len(all_extracted_items)}")
303
+ print(f"Примеры форматов пунктов: {', '.join(sorted(list(all_extracted_items))[:20])}")
304
+
305
+ print(f"\nВсего извлечено уникальных приложений: {len(all_extracted_appendices)}")
306
+ print(f"Форматы приложений: {', '.join(sorted(list(all_extracted_appendices)))}")
307
+
308
+ # Подсчитываем общее количество пунктов и приложений
309
+ total_puncts = sum(len(doc_data['puncts']) for doc_data in item_texts.values())
310
+ total_appendices = sum(len(doc_data['appendices']) for doc_data in item_texts.values())
311
+
312
+ print(f"Извлечено {total_puncts} пунктов и {total_appendices} приложений из {len(item_texts)} документов")
313
+
314
+ return item_texts
315
+
316
+
317
+ def is_subpunct(parent_punct: str, possible_subpunct: str) -> bool:
318
+ """
319
+ Проверяет, является ли пункт подпунктом другого пункта.
320
+
321
+ Args:
322
+ parent_punct: Родительский пункт (например, "14")
323
+ possible_subpunct: Возможный подпункт (например, "14.1")
324
+
325
+ Returns:
326
+ True, если possible_subpunct является подпунктом parent_punct
327
+ """
328
+ # Нормализуем пункты
329
+ parent = normalize_punct_format(parent_punct)
330
+ child = normalize_punct_format(possible_subpunct)
331
+
332
+ # Проверяем, начинается ли child с parent и после него идет точка или другой разделитель
333
+ if child.startswith(parent):
334
+ # Если длины равны, это тот же самый пункт
335
+ if len(child) == len(parent):
336
+ return False
337
+
338
+ # Проверяем символ после parent - должна быть точка (дефис исключен, т.к. это разные пункты)
339
+ next_char = child[len(parent)]
340
+ return next_char in ['.']
341
+
342
+ return False
343
+
344
+
345
+ def collect_subpuncts(punct: str, all_puncts: dict) -> dict:
346
+ """
347
+ Собирает все подпункты для указанного пункта.
348
+
349
+ Args:
350
+ punct: Пункт, для которого нужно найти подпункты (например, "14")
351
+ all_puncts: Словарь всех пунктов {punct: text}
352
+
353
+ Returns:
354
+ Словарь {punct: text} с пунктом и всеми его подпунктами
355
+ """
356
+ result = {}
357
+ normalized_punct = normalize_punct_format(punct)
358
+
359
+ # Добавляем сам пункт, если он существует
360
+ if normalized_punct in all_puncts:
361
+ result[normalized_punct] = all_puncts[normalized_punct]
362
+
363
+ # Ищем подпункты
364
+ for possible_subpunct in all_puncts.keys():
365
+ if is_subpunct(normalized_punct, possible_subpunct):
366
+ result[possible_subpunct] = all_puncts[possible_subpunct]
367
+
368
+ return result
369
+
370
+
371
+ def prepare_expanded_dataset(df, item_texts, output_path, debug_mode=False):
372
+ """
373
+ Подготавливает расширенный датасет, добавляя тексты пунктов и приложений.
374
+
375
+ Args:
376
+ df: Исходный датасет
377
+ item_texts: Словарь с текстами пунктов и приложений
378
+ output_path: Путь для сохранения расширенного датасета
379
+ debug_mode: Включать ли режим отладки
380
+
381
+ Returns:
382
+ Датафрейм с расширенным датасетом
383
+ """
384
+ rows = []
385
+ skipped_items = 0
386
+ total_items = 0
387
+
388
+ for _, row in df.iterrows():
389
+ question_id = row['id']
390
+ question = row['question']
391
+ filepath = row.get('filepath', '')
392
+
393
+ # Получаем имя файла без пути
394
+ doc_name = Path(filepath).stem if filepath else ''
395
+
396
+ # Пропускаем, если файл не найден
397
+ if not doc_name or doc_name not in item_texts:
398
+ if debug_mode and doc_name:
399
+ print(f"Документ {doc_name} не найден в извлеченных данных")
400
+ continue
401
+
402
+ # Обрабатываем пункты
403
+ puncts = row.get('puncts', [])
404
+ if isinstance(puncts, str) and puncts.strip():
405
+ # Преобразуем строковое представление в список
406
+ try:
407
+ puncts = eval(puncts)
408
+ except:
409
+ puncts = []
410
+
411
+ if not isinstance(puncts, list):
412
+ puncts = []
413
+
414
+ for punct in puncts:
415
+ total_items += 1
416
+
417
+ if debug_mode:
418
+ print(f"\nОбработка пункта {punct} для вопроса {question_id} из {doc_name}")
419
+
420
+ # Ищем соответствующий пункт в документе
421
+ available_keys = list(item_texts[doc_name]['puncts'].keys())
422
+ matching_key = find_matching_key(punct, available_keys, 'punct', debug_mode)
423
+
424
+ if matching_key:
425
+ # Сохраняем основной текст пункта
426
+ item_text = item_texts[doc_name]['puncts'][matching_key]
427
+
428
+ # Список всех включенных ключей (для отслеживания что было приконкатенировано)
429
+ matched_keys = [matching_key]
430
+
431
+ # Ищем все подпункты для этого пункта
432
+ subpuncts = {}
433
+ for key in available_keys:
434
+ if is_subpunct(matching_key, key):
435
+ subpuncts[key] = item_texts[doc_name]['puncts'][key]
436
+ matched_keys.append(key)
437
+
438
+ # Если есть подпункты, добавляем их к основному тексту
439
+ if subpuncts:
440
+ # Сортируем подпункты по номеру
441
+ sorted_subpuncts = sorted(subpuncts.items(), key=lambda x: x[0])
442
+
443
+ # Добавляем разделитель и все подпункты
444
+ combined_text = item_text
445
+ for key, subtext in sorted_subpuncts:
446
+ combined_text += f"\n\n{key} {subtext}"
447
+
448
+ item_text = combined_text
449
+
450
+ # Добавляем строку с пунктом и его подпунктами
451
+ rows.append({
452
+ 'id': question_id,
453
+ 'question': question,
454
+ 'filename': doc_name,
455
+ 'text': item_text,
456
+ 'item_type': 'punct',
457
+ 'item_id': punct,
458
+ 'matching_keys': ", ".join(matched_keys)
459
+ })
460
+
461
+ if debug_mode:
462
+ print(f"Добавлен пункт {matching_key} для {question_id} с {len(matched_keys)} ключами")
463
+ if len(matched_keys) > 1:
464
+ print(f" Включены ключи: {', '.join(matched_keys)}")
465
+ else:
466
+ skipped_items += 1
467
+ if debug_mode:
468
+ print(f"Не найден соответствующий пункт для {punct} в {doc_name}")
469
+
470
+ # Обрабатываем приложения
471
+ appendices = row.get('appendices', [])
472
+ if isinstance(appendices, str) and appendices.strip():
473
+ # Преобразуем строковое представление в список
474
+ try:
475
+ appendices = eval(appendices)
476
+ except:
477
+ appendices = []
478
+
479
+ if not isinstance(appendices, list):
480
+ appendices = []
481
+
482
+ for appendix in appendices:
483
+ total_items += 1
484
+
485
+ if debug_mode:
486
+ print(f"\nОбработка приложения {appendix} для вопроса {question_id} из {doc_name}")
487
+
488
+ # Ищем соответствующее приложение в документе
489
+ available_keys = list(item_texts[doc_name]['appendices'].keys())
490
+ matching_key = find_matching_key(appendix, available_keys, 'appendix', debug_mode)
491
+
492
+ if matching_key:
493
+ appendix_content = item_texts[doc_name]['appendices'][matching_key]
494
+
495
+ # Список всех включенных ключей (для отслеживания что было приконкатенировано)
496
+ matched_keys = [matching_key]
497
+
498
+ # Формируем полный текст приложения, включая все подпункты
499
+ if isinstance(appendix_content, dict):
500
+ # Начинаем с основного текста
501
+ full_text = appendix_content.get('main_text', '')
502
+
503
+ # Добавляем все подпункты в отсортированном порядке
504
+ if 'subpuncts' in appendix_content and appendix_content['subpuncts']:
505
+ subpuncts = appendix_content['subpuncts']
506
+ sorted_subpuncts = sorted(subpuncts.items(), key=lambda x: x[0])
507
+
508
+ # Добавляем разделитель, если есть основной текст
509
+ if full_text:
510
+ full_text += "\n\n"
511
+
512
+ # Добавляем все подпункты
513
+ for i, (key, subtext) in enumerate(sorted_subpuncts):
514
+ matched_keys.append(f"{matching_key}/{key}")
515
+ if i > 0:
516
+ full_text += "\n\n"
517
+ full_text += f"{key} {subtext}"
518
+ else:
519
+ # Если приложение просто строка
520
+ full_text = appendix_content
521
+
522
+ # Добавляем строку с приложением
523
+ rows.append({
524
+ 'id': question_id,
525
+ 'question': question,
526
+ 'filename': doc_name,
527
+ 'text': full_text,
528
+ 'item_type': 'appendix',
529
+ 'item_id': appendix,
530
+ 'matching_keys': ", ".join(matched_keys)
531
+ })
532
+
533
+ if debug_mode:
534
+ print(f"Добавлено приложение {matching_key} для {question_id} с {len(matched_keys)} ключами")
535
+ if len(matched_keys) > 1:
536
+ print(f" Включены ключи: {', '.join(matched_keys)}")
537
+ else:
538
+ skipped_items += 1
539
+ if debug_mode:
540
+ print(f"Не найдено соответствующее п��иложение для {appendix} в {doc_name}")
541
+
542
+ extended_df = pd.DataFrame(rows)
543
+
544
+ # Сохраняем расширенный датасет
545
+ extended_df.to_excel(output_path, index=False)
546
+
547
+ print(f"Расширенный датасет сохранен в {output_path}")
548
+ print(f"Всего обработано элементов: {total_items}")
549
+ print(f"Всего элементов в расширенном датасете: {len(extended_df)}")
550
+ print(f"Пропущено элементов из-за отсутствия соответствия: {skipped_items}")
551
+
552
+ return extended_df
553
+
554
+
555
+ def main():
556
+ # Парсим аргументы командной строки
557
+ args = parse_args()
558
+
559
+ # Определяем режим отладки
560
+ debug = args.debug
561
+
562
+ # Загружаем исходный датасет
563
+ df = load_dataset(args.input_dataset, debug)
564
+
565
+ # Читаем документы
566
+ documents = read_documents(args.data_folder)
567
+
568
+ # Извлекаем тексты пунктов и приложений
569
+ item_texts = extract_item_texts(documents, debug)
570
+
571
+ # Подготавливаем расширенный датасет
572
+ expanded_df = prepare_expanded_dataset(df, item_texts, args.output_dataset, debug)
573
+
574
+ print("Готово!")
575
+
576
+
577
+ if __name__ == "__main__":
578
+ main()
lib/extractor/scripts/run_chunking_experiments.sh ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Скрипт для запуска экспериментов по оценке качества чанкинга с разными моделями и параметрами
3
+
4
+ # Директории и пути по умолчанию
5
+ DATA_FOLDER="data/docs"
6
+ DATASET_PATH="data/dataset.xlsx"
7
+ OUTPUT_DIR="data"
8
+ LOG_DIR="logs"
9
+ SIMILARITY_THRESHOLD=0.7
10
+ DEVICE="cuda:1"
11
+
12
+ # Создаем директории, если они не существуют
13
+ mkdir -p "$OUTPUT_DIR"
14
+ mkdir -p "$LOG_DIR"
15
+
16
+ # Список моделей для тестирования
17
+ MODELS=(
18
+ "intfloat/e5-base"
19
+ "intfloat/e5-large"
20
+ "BAAI/bge-m3"
21
+ "deepvk/USER-bge-m3"
22
+ "ai-forever/FRIDA"
23
+ )
24
+
25
+ # Параметры чанкинга (отсортированы в запрошенном порядке)
26
+ # Формат: [слов_в_чанке]:[нахлест]:[описание]
27
+ CHUNKING_PARAMS=(
28
+ "50:25:Маленький чанкинг с нахлёстом 50%"
29
+ "50:0:Маленький чанкинг без нахлёста"
30
+ "20:10:Очень мелкий чанкинг с нахлёстом 50%"
31
+ "100:0:Средний чанкинг без нахлёста"
32
+ "100:25:Средний чанкинг с нахлёстом 25%"
33
+ "150:50:Крупный чанкинг с нахлёстом 33%"
34
+ "200:75:Очень крупный чанкинг с нахлёстом 37.5%"
35
+ )
36
+
37
+ # Функция для запуска одного эксперимента
38
+ run_experiment() {
39
+ local model="$1"
40
+ local words="$2"
41
+ local overlap="$3"
42
+ local description="$4"
43
+
44
+ # Заменяем слеши в имени модели на подчеркивания для имен файлов
45
+ local model_safe_name=$(echo "$model" | tr '/' '_')
46
+
47
+ # Формируем имя файла результатов
48
+ local results_filename="results_fixed_size_w${words}_o${overlap}_${model_safe_name}.csv"
49
+ local results_path="${OUTPUT_DIR}/${results_filename}"
50
+
51
+ # Формируем имя файла лога
52
+ local timestamp=$(date +"%Y%m%d_%H%M%S")
53
+ local log_filename="log_${model_safe_name}_w${words}_o${overlap}_${timestamp}.txt"
54
+ local log_path="${LOG_DIR}/${log_filename}"
55
+
56
+ echo "=============================================================================="
57
+ echo "Запуск эксперимента:"
58
+ echo " Модель: $model"
59
+ echo " Чанкинг: $description (words=$words, overlap=$overlap)"
60
+ echo " Устройство: $DEVICE"
61
+ echo " Результаты будут сохранены в: $results_path"
62
+ echo " Лог: $log_path"
63
+ echo "=============================================================================="
64
+
65
+ # Базовая команда запуска
66
+ local cmd="python scripts/evaluate_chunking.py \
67
+ --data-folder \"$DATA_FOLDER\" \
68
+ --model-name \"$model\" \
69
+ --dataset-path \"$DATASET_PATH\" \
70
+ --output-dir \"$OUTPUT_DIR\" \
71
+ --words-per-chunk $words \
72
+ --overlap-words $overlap \
73
+ --similarity-threshold $SIMILARITY_THRESHOLD \
74
+ --device $DEVICE \
75
+ --force-recompute"
76
+
77
+ # Специальная обработка для модели ai-forever/FRIDA
78
+ if [[ "$model" == "ai-forever/FRIDA" ]]; then
79
+ cmd="$cmd --use-sentence-transformers"
80
+ fi
81
+
82
+ # Записываем информацию о запуске в лог
83
+ echo "Эксперимент запущен в: $(date)" > "$log_path"
84
+ echo "Команда: $cmd" >> "$log_path"
85
+ echo "" >> "$log_path"
86
+
87
+ # Записываем время начала
88
+ start_time=$(date +%s)
89
+
90
+ # Запускаем команду и записываем вывод в лог
91
+ eval "$cmd" 2>&1 | tee -a "$log_path"
92
+ exit_code=${PIPESTATUS[0]}
93
+
94
+ # Записываем время окончания
95
+ end_time=$(date +%s)
96
+ duration=$((end_time - start_time))
97
+ duration_min=$(echo "scale=2; $duration/60" | bc)
98
+
99
+ # Добавляем информацию о завершении в лог
100
+ echo "" >> "$log_path"
101
+ echo "Эксперимент завершен в: $(date)" >> "$log_path"
102
+ echo "Длительность: $duration секунд ($duration_min минут)" >> "$log_path"
103
+ echo "Код возврата: $exit_code" >> "$log_path"
104
+
105
+ if [ $exit_code -eq 0 ]; then
106
+ echo "Эксперимент успешно завершен за $duration_min минут"
107
+ else
108
+ echo "Эксперимент завершился с ошибкой (код $exit_code)"
109
+ fi
110
+ }
111
+
112
+ # Основная функция
113
+ main() {
114
+ local total_experiments=$((${#MODELS[@]} * ${#CHUNKING_PARAMS[@]}))
115
+ local completed_experiments=0
116
+
117
+ echo "Запуск $total_experiments экспериментов..."
118
+
119
+ # Засекаем время начала всех экспериментов
120
+ local start_time_all=$(date +%s)
121
+
122
+ # Сначала перебираем все параметры чанкинга
123
+ for chunking_param in "${CHUNKING_PARAMS[@]}"; do
124
+ # Разбиваем строку параметров на составляющие
125
+ IFS=':' read -r words overlap description <<< "$chunking_param"
126
+
127
+ echo -e "\n=== Стратегия чанкинга: $description (words=$words, overlap=$overlap) ===\n"
128
+
129
+ # Затем перебираем все модели для текущей стратегии чанкинга
130
+ for model in "${MODELS[@]}"; do
131
+ # Запускаем эксперимент
132
+ run_experiment "$model" "$words" "$overlap" "$description"
133
+
134
+ # Увеличиваем счетчик завершенных экспериментов
135
+ completed_experiments=$((completed_experiments + 1))
136
+ remaining_experiments=$((total_experiments - completed_experiments))
137
+
138
+ if [ $remaining_experiments -gt 0 ]; then
139
+ echo "Завершено $completed_experiments/$total_experiments экспериментов. Осталось: $remaining_experiments"
140
+ fi
141
+ done
142
+ done
143
+
144
+ # Рассчитываем общее время выполнения
145
+ local end_time_all=$(date +%s)
146
+ local total_duration=$((end_time_all - start_time_all))
147
+ local total_duration_min=$(echo "scale=2; $total_duration/60" | bc)
148
+
149
+ echo ""
150
+ echo "Все эксперименты завершены за $total_duration_min минут"
151
+ echo "Результаты сохранены в $OUTPUT_DIR"
152
+ echo "Логи сохранены в $LOG_DIR"
153
+ }
154
+
155
+ # Запускаем основную функцию
156
+ main
lib/extractor/scripts/run_experiments.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для запуска экспериментов по оценке качества чанкинга с разными моделями и параметрами.
4
+ """
5
+
6
+ import argparse
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from datetime import datetime
12
+
13
+ # Конфигурация моделей
14
+ MODELS = [
15
+ "intfloat/e5-base",
16
+ "intfloat/e5-large",
17
+ "BAAI/bge-m3",
18
+ "deepvk/USER-bge-m3",
19
+ "ai-forever/FRIDA"
20
+ ]
21
+
22
+ # Параметры чанкинга (отсортированы в запрошенном порядке)
23
+ CHUNKING_PARAMS = [
24
+ {"words": 50, "overlap": 25, "description": "Маленький чанкинг с нахлёстом 50%"},
25
+ {"words": 50, "overlap": 0, "description": "Маленький чанкинг без нахлёста"},
26
+ {"words": 20, "overlap": 10, "description": "Очень мелкий чанкинг с нахлёстом 50%"},
27
+ {"words": 100, "overlap": 0, "description": "Средний чанкинг без нахлёста"},
28
+ {"words": 100, "overlap": 25, "description": "Средний чанкинг с нахлёстом 25%"},
29
+ {"words": 150, "overlap": 50, "description": "Крупный чанкинг с нахлёстом 33%"},
30
+ {"words": 200, "overlap": 75, "description": "Очень крупный чанкинг с нахлёстом 37.5%"}
31
+ ]
32
+
33
+ # Значение порога для нечеткого сравнения
34
+ SIMILARITY_THRESHOLD = 0.7
35
+
36
+
37
+ def parse_args():
38
+ """Парсит аргументы командной строки."""
39
+ parser = argparse.ArgumentParser(description="Запуск экспериментов для оценки качества чанкинга")
40
+
41
+ parser.add_argument("--data-folder", type=str, default="data/docs",
42
+ help="Путь к папке с документами (по умолчанию: data/docs)")
43
+ parser.add_argument("--dataset-path", type=str, default="data/dataset.xlsx",
44
+ help="Путь к Excel-датасету с вопросами (по умолчанию: data/dataset.xlsx)")
45
+ parser.add_argument("--output-dir", type=str, default="data",
46
+ help="Директория для сохранения результатов (по умолчанию: data)")
47
+ parser.add_argument("--log-dir", type=str, default="logs",
48
+ help="Директория для сохранения логов (по умолчанию: logs)")
49
+ parser.add_argument("--skip-existing", action="store_true",
50
+ help="Пропускать эксперименты, если файлы результатов уже существуют")
51
+ parser.add_argument("--similarity-threshold", type=float, default=SIMILARITY_THRESHOLD,
52
+ help=f"Порог для нечеткого сравнения (по умолчанию: {SIMILARITY_THRESHOLD})")
53
+ parser.add_argument("--model", type=str, default=None,
54
+ help="Запустить эксперимент только для указанной модели")
55
+ parser.add_argument("--chunking-index", type=int, default=None,
56
+ help="Запустить эксперимент только для указанного индекса конфигурации чанкинга (0-6)")
57
+ parser.add_argument("--device", type=str, default="cuda:1",
58
+ help="Устройство для вычислений (по умолчанию: cuda:1)")
59
+
60
+ return parser.parse_args()
61
+
62
+
63
+ def run_experiment(model_name, chunking_params, args):
64
+ """
65
+ Запускает эксперимент с определенной моделью и параметрами чанкинга.
66
+
67
+ Args:
68
+ model_name: Название модели
69
+ chunking_params: Словарь с параметрами чанкинга
70
+ args: Аргументы командной строки
71
+ """
72
+ words = chunking_params["words"]
73
+ overlap = chunking_params["overlap"]
74
+ description = chunking_params["description"]
75
+
76
+ # Формируем имя файла результатов
77
+ results_filename = f"results_fixed_size_w{words}_o{overlap}_{model_name.replace('/', '_')}.csv"
78
+ results_path = os.path.join(args.output_dir, results_filename)
79
+
80
+ # Проверяем, существует ли файл результатов
81
+ if args.skip_existing and os.path.exists(results_path):
82
+ print(f"Пропуск: {results_path} уже существует")
83
+ return
84
+
85
+ # Создаем директорию для логов, если она не существует
86
+ os.makedirs(args.log_dir, exist_ok=True)
87
+
88
+ # Формируем имя файла лога
89
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
90
+ log_filename = f"log_{model_name.replace('/', '_')}_w{words}_o{overlap}_{timestamp}.txt"
91
+ log_path = os.path.join(args.log_dir, log_filename)
92
+
93
+ # Используем тот же интерпретатор Python, что и текущий скрипт
94
+ python_executable = sys.executable
95
+
96
+ # Запускаем скрипт evaluate_chunking.py с нужными параметрами
97
+ cmd = [
98
+ python_executable, "scripts/evaluate_chunking.py",
99
+ "--data-folder", args.data_folder,
100
+ "--model-name", model_name,
101
+ "--dataset-path", args.dataset_path,
102
+ "--output-dir", args.output_dir,
103
+ "--words-per-chunk", str(words),
104
+ "--overlap-words", str(overlap),
105
+ "--similarity-threshold", str(args.similarity_threshold),
106
+ "--device", args.device,
107
+ "--force-recompute" # Принудительно пересчитываем эмбеддинги
108
+ ]
109
+
110
+ # Специальная обработка для модели ai-forever/FRIDA
111
+ if model_name == "ai-forever/FRIDA":
112
+ cmd.append("--use-sentence-transformers") # Добавляем флаг для использования sentence_transformers
113
+
114
+ print(f"\n{'='*80}")
115
+ print(f"Запуск эксперимента:")
116
+ print(f" Интерпретатор Python: {python_executable}")
117
+ print(f" Модель: {model_name}")
118
+ print(f" Чанкинг: {description} (words={words}, overlap={overlap})")
119
+ print(f" Порог для нечеткого сравнения: {args.similarity_threshold}")
120
+ print(f" Устройство: {args.device}")
121
+ print(f" Результаты будут сохранены в: {results_path}")
122
+ print(f" Лог: {log_path}")
123
+ print(f"{'='*80}\n")
124
+
125
+ # Запись информации в лог
126
+ with open(log_path, "w", encoding="utf-8") as log_file:
127
+ log_file.write(f"Эксперимент запущен в: {datetime.now()}\n")
128
+ log_file.write(f"Интерпретатор Python: {python_executable}\n")
129
+ log_file.write(f"Команда: {' '.join(cmd)}\n\n")
130
+
131
+ start_time = time.time()
132
+
133
+ # Запускаем процесс и перенаправляем вывод в файл лога
134
+ process = subprocess.Popen(
135
+ cmd,
136
+ stdout=subprocess.PIPE,
137
+ stderr=subprocess.STDOUT,
138
+ text=True,
139
+ bufsize=1 # Построчная буферизация
140
+ )
141
+
142
+ # Читаем вывод процесса
143
+ for line in process.stdout:
144
+ print(line, end="") # Выводим в консоль
145
+ log_file.write(line) # Записываем в файл лога
146
+
147
+ # Ждем завершения процесса
148
+ process.wait()
149
+
150
+ end_time = time.time()
151
+ duration = end_time - start_time
152
+
153
+ # Записываем информацию о завершении
154
+ log_file.write(f"\nЭксперимент завершен в: {datetime.now()}\n")
155
+ log_file.write(f"Длительность: {duration:.2f} секунд ({duration/60:.2f} минут)\n")
156
+ log_file.write(f"Код возврата: {process.returncode}\n")
157
+
158
+ if process.returncode == 0:
159
+ print(f"Эксперимент успешно завершен за {duration/60:.2f} минут")
160
+ else:
161
+ print(f"Эксперимент завершился с ошибкой (код {process.returncode})")
162
+
163
+
164
+ def main():
165
+ """Основная функция скрипта."""
166
+ args = parse_args()
167
+
168
+ # Создаем output_dir, если он не существует
169
+ os.makedirs(args.output_dir, exist_ok=True)
170
+
171
+ # Получаем список моделей для запуска
172
+ models_to_run = [args.model] if args.model else MODELS
173
+
174
+ # Получаем список конфигураций чанкинга для запуска
175
+ chunking_configs = [CHUNKING_PARAMS[args.chunking_index]] if args.chunking_index is not None else CHUNKING_PARAMS
176
+
177
+ start_time_all = time.time()
178
+ total_experiments = len(models_to_run) * len(chunking_configs)
179
+ completed_experiments = 0
180
+
181
+ print(f"Запуск {total_experiments} экспериментов...")
182
+
183
+ # Изменен порядок: сначала идём по стратегиям, затем по моделям
184
+ for chunking_config in chunking_configs:
185
+ print(f"\n=== Стратегия чанкинга: {chunking_config['description']} (words={chunking_config['words']}, overlap={chunking_config['overlap']}) ===\n")
186
+
187
+ for model in models_to_run:
188
+ # Запускаем эксперимент
189
+ run_experiment(model, chunking_config, args)
190
+
191
+ completed_experiments += 1
192
+ remaining_experiments = total_experiments - completed_experiments
193
+
194
+ if remaining_experiments > 0:
195
+ print(f"Завершено {completed_experiments}/{total_experiments} экспериментов. Осталось: {remaining_experiments}")
196
+
197
+ end_time_all = time.time()
198
+ total_duration = end_time_all - start_time_all
199
+
200
+ print(f"\nВсе эксперименты завершены за {total_duration/60:.2f} минут")
201
+ print(f"Результаты сохранены в {args.output_dir}")
202
+ print(f"Логи сохранены в {args.log_dir}")
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
lib/extractor/scripts/search_api.py ADDED
@@ -0,0 +1,748 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для поиска по векторизованным документам через API.
4
+
5
+ Этот скрипт:
6
+ 1. Считывает все документы из заданной папки с помощью UniversalParser
7
+ 2. Чанкит каждый документ через Destructurer с fixed_size-стратегией
8
+ 3. Векторизует поле in_search_text через BGE-модель
9
+ 4. Поднимает FastAPI с двумя эндпоинтами:
10
+ - /search/entities - возвращает найденные сущности списком словарей
11
+ - /search/text - возвращает полноценный собранный текст
12
+ """
13
+
14
+ import logging
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Dict, List, Optional
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import torch
22
+ import uvicorn
23
+ from fastapi import FastAPI, Query
24
+ from ntr_fileparser import UniversalParser
25
+ from pydantic import BaseModel
26
+ from sklearn.metrics.pairwise import cosine_similarity
27
+ from transformers import AutoModel, AutoTokenizer
28
+
29
+ from ntr_text_fragmentation.chunking.specific_strategies.fixed_size_chunking import \
30
+ FixedSizeChunkingStrategy
31
+ from ntr_text_fragmentation.core.destructurer import Destructurer
32
+ from ntr_text_fragmentation.core.entity_repository import \
33
+ InMemoryEntityRepository
34
+ from ntr_text_fragmentation.core.injection_builder import InjectionBuilder
35
+ from ntr_text_fragmentation.models.linker_entity import LinkerEntity
36
+
37
+ # Константы
38
+ DOCS_FOLDER = "../data/docs" # Путь к папке с документами
39
+ MODEL_NAME = "BAAI/bge-m3" # Название модели для векторизации
40
+ BATCH_SIZE = 16 # Размер батча для векторизации
41
+ DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu" # Устройство для вычислений
42
+ MAX_ENTITIES = 100 # Максимальное количество возвращаемых сущностей
43
+ WORDS_PER_CHUNK = 50 # Количество слов в чанке для fixed_size стратегии
44
+ OVERLAP_WORDS = 25 # Количество слов перекрытия для fixed_size стратегии
45
+
46
+ # Пути к кэшированным файлам
47
+ CACHE_DIR = "../data/cache" # Путь к папке с кэшированными данными
48
+ ENTITIES_CSV = os.path.join(CACHE_DIR, "entities.csv") # Путь к CSV с сущностями
49
+ EMBEDDINGS_NPY = os.path.join(CACHE_DIR, "embeddings.npy") # Путь к массиву эмбеддингов
50
+
51
+ # Инициализация FastAPI
52
+ app = FastAPI(title="Документный поиск API",
53
+ description="API для поиска по векторизованным документам")
54
+
55
+ # Глобальные переменные для хранения данных
56
+ entities_df = None
57
+ entity_embeddings = None
58
+ model = None
59
+ tokenizer = None
60
+ entity_repository = None
61
+ injection_builder = None
62
+
63
+
64
+ class EntityResponse(BaseModel):
65
+ """Модель ответа для сущностей."""
66
+ id: str
67
+ name: str
68
+ text: str
69
+ type: str
70
+ score: float
71
+ doc_name: Optional[str] = None
72
+ metadata: Optional[Dict] = None
73
+
74
+
75
+ class TextResponse(BaseModel):
76
+ """Модель ответа для собранного текста."""
77
+ text: str
78
+ entities_count: int
79
+
80
+
81
+ class TextsResponse(BaseModel):
82
+ """Модель ответа для списка текстов."""
83
+ texts: List[str]
84
+ entities_count: int
85
+
86
+
87
+ def setup_logging() -> None:
88
+ """Настройка логгирования."""
89
+ logging.basicConfig(
90
+ level=logging.INFO,
91
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
92
+ )
93
+
94
+
95
+ def load_documents(folder_path: str) -> Dict:
96
+ """
97
+ Загружает все документы из указанной папки.
98
+
99
+ Args:
100
+ folder_path: Путь к папке с документами
101
+
102
+ Returns:
103
+ Словарь {имя_файла: parsed_document}
104
+ """
105
+ logging.info(f"Чтение документов из {folder_path}...")
106
+ parser = UniversalParser()
107
+ documents = {}
108
+
109
+ # Проверка существования папки
110
+ if not os.path.exists(folder_path):
111
+ logging.error(f"Папка {folder_path} не существует!")
112
+ return {}
113
+
114
+ for file_path in Path(folder_path).glob("**/*.docx"):
115
+ try:
116
+ doc_name = file_path.stem
117
+ logging.info(f"Обработка документа: {doc_name}")
118
+ documents[doc_name] = parser.parse_by_path(str(file_path))
119
+ except Exception as e:
120
+ logging.error(f"Ошибка при чтении файла {file_path}: {e}")
121
+
122
+ logging.info(f"Загружено {len(documents)} документов.")
123
+ return documents
124
+
125
+
126
+ def process_documents(documents: Dict) -> List[LinkerEntity]:
127
+ """
128
+ Обрабатывает документы, применяя fixed_size стратегию чанкинга.
129
+
130
+ Args:
131
+ documents: Словарь с распарсенными документами
132
+
133
+ Returns:
134
+ Список сущностей из всех документов
135
+ """
136
+ logging.info("Применение fixed_size стратегии чанкинга ко всем документам...")
137
+
138
+ all_entities = []
139
+
140
+ for doc_name, document in documents.items():
141
+ try:
142
+ # Создаем Destructurer с fixed_size стратегией
143
+ destructurer = Destructurer(
144
+ document,
145
+ strategy_name="fixed_size",
146
+ words_per_chunk=WORDS_PER_CHUNK,
147
+ overlap_words=OVERLAP_WORDS
148
+ )
149
+
150
+ # Получаем сущности
151
+ doc_entities = destructurer.destructure()
152
+
153
+ # Добавляем имя документа в метаданные всех сущностей
154
+ for entity in doc_entities:
155
+ if not hasattr(entity, 'metadata') or entity.metadata is None:
156
+ entity.metadata = {}
157
+ entity.metadata['doc_name'] = doc_name
158
+
159
+ all_entities.extend(doc_entities)
160
+ logging.info(f"Документ {doc_name}: получено {len(doc_entities)} сущностей")
161
+
162
+ except Exception as e:
163
+ logging.error(f"Ошибка при обработке документа {doc_name}: {e}")
164
+
165
+ logging.info(f"Всего получено {len(all_entities)} сущностей из всех документов")
166
+ return all_entities
167
+
168
+
169
+ def entities_to_dataframe(entities: List[LinkerEntity]) -> pd.DataFrame:
170
+ """
171
+ Преобразует список сущностей в DataFrame для удобной работы.
172
+
173
+ Args:
174
+ entities: Список сущностей
175
+
176
+ Returns:
177
+ DataFrame с данными сущностей
178
+ """
179
+ data = []
180
+
181
+ for entity in entities:
182
+ # Получаем имя документа из метаданных
183
+ doc_name = entity.metadata.get('doc_name', '') if hasattr(entity, 'metadata') and entity.metadata else ''
184
+
185
+ # Базовые поля для всех типов сущностей
186
+ entity_dict = {
187
+ "id": str(entity.id),
188
+ "type": entity.type,
189
+ "name": entity.name,
190
+ "text": entity.text,
191
+ "in_search_text": entity.in_search_text,
192
+ "doc_name": doc_name,
193
+ "source_id": entity.source_id if hasattr(entity, 'source_id') else None,
194
+ "target_id": entity.target_id if hasattr(entity, 'target_id') else None,
195
+ "metadata": entity.metadata if hasattr(entity, 'metadata') else {},
196
+ }
197
+
198
+ data.append(entity_dict)
199
+
200
+ df = pd.DataFrame(data)
201
+ return df
202
+
203
+
204
+ def setup_model_and_tokenizer():
205
+ """
206
+ Инициализирует модель и токенизатор для векторизации.
207
+
208
+ Returns:
209
+ Кортеж (модель, токенизатор)
210
+ """
211
+ global model, tokenizer
212
+
213
+ logging.info(f"Загрузка модели {MODEL_NAME} на устройство {DEVICE}...")
214
+
215
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
216
+ model = AutoModel.from_pretrained(MODEL_NAME).to(DEVICE)
217
+ model.eval()
218
+
219
+ return model, tokenizer
220
+
221
+
222
+ def _average_pool(
223
+ last_hidden_states: torch.Tensor,
224
+ attention_mask: torch.Tensor
225
+ ) -> torch.Tensor:
226
+ """
227
+ Расчёт усредненного эмбеддинга по всем токенам
228
+
229
+ Args:
230
+ last_hidden_states: Матрица эмбеддингов отдельных токенов
231
+ attention_mask: Маска, чтобы не учитывать при усреднении пустые токены
232
+
233
+ Returns:
234
+ Усредненный эмбеддинг
235
+ """
236
+ last_hidden = last_hidden_states.masked_fill(
237
+ ~attention_mask[..., None].bool(), 0.0
238
+ )
239
+ return last_hidden.sum(dim=1) / attention_mask.sum(dim=1)[..., None]
240
+
241
+
242
+ def get_embeddings(texts: List[str]) -> np.ndarray:
243
+ """
244
+ Получает эмбеддинги для списка текстов.
245
+
246
+ Args:
247
+ texts: Список текстов для векторизации
248
+
249
+ Returns:
250
+ Массив эмбеддингов
251
+ """
252
+ global model, tokenizer
253
+
254
+ # Проверяем, что модель и токенизатор инициализированы
255
+ if model is None or tokenizer is None:
256
+ model, tokenizer = setup_model_and_tokenizer()
257
+
258
+ all_embeddings = []
259
+
260
+ for i in range(0, len(texts), BATCH_SIZE):
261
+ batch_texts = texts[i:i+BATCH_SIZE]
262
+
263
+ # Фильтруем None и пустые строки
264
+ batch_texts = [text for text in batch_texts if text]
265
+
266
+ if not batch_texts:
267
+ continue
268
+
269
+ # Токенизация с обрезкой и padding
270
+ encoding = tokenizer(
271
+ batch_texts,
272
+ padding=True,
273
+ truncation=True,
274
+ max_length=512,
275
+ return_tensors="pt"
276
+ ).to(DEVICE)
277
+
278
+ # Получаем эмбеддинги с average pooling
279
+ with torch.no_grad():
280
+ outputs = model(**encoding)
281
+ embeddings = _average_pool(outputs.last_hidden_state, encoding["attention_mask"])
282
+ all_embeddings.append(embeddings.cpu().numpy())
283
+
284
+ if not all_embeddings:
285
+ return np.array([])
286
+
287
+ return np.vstack(all_embeddings)
288
+
289
+
290
+ def init_entity_repository_and_builder(entities: List[LinkerEntity]):
291
+ """
292
+ Инициализирует хранилище сущностей и сборщик инъекций.
293
+
294
+ Args:
295
+ entities: Список сущностей
296
+ """
297
+ global entity_repository, injection_builder
298
+
299
+ # Создаем хранилище сущностей
300
+ entity_repository = InMemoryEntityRepository(entities)
301
+
302
+ # Добавляем метод get_entity_by_id в InMemoryEntityRepository
303
+ # Это временное решение, в идеале нужно добавить этот метод в сам класс
304
+ def get_entity_by_id(self, entity_id):
305
+ """Получает сущность по ID"""
306
+ for entity in self.entities:
307
+ if str(entity.id) == entity_id:
308
+ return entity
309
+ return None
310
+
311
+ # Добавляем метод в класс
312
+ InMemoryEntityRepository.get_entity_by_id = get_entity_by_id
313
+
314
+ # Создаем сборщик инъекций
315
+ injection_builder = InjectionBuilder(repository=entity_repository)
316
+
317
+ # Регистрируем стратегию
318
+ injection_builder.register_strategy("fixed_size", FixedSizeChunkingStrategy)
319
+
320
+
321
+ def search_entities(query: str, top_n: int = MAX_ENTITIES) -> List[Dict]:
322
+ """
323
+ Ищет сущности по запросу на основе косинусной близости.
324
+
325
+ Args:
326
+ query: Поисковый запрос
327
+ top_n: Максимальное количество возвращаемых сущностей
328
+
329
+ Returns:
330
+ Список найденных сущностей с их скорами
331
+ """
332
+ global entities_df, entity_embeddings
333
+
334
+ # Проверяем наличие данных
335
+ if entities_df is None or entity_embeddings is None:
336
+ logging.error("Данные не инициализированы. Запустите сначала prepare_data().")
337
+ return []
338
+
339
+ # Векторизуем запрос
340
+ query_embedding = get_embeddings([query])
341
+
342
+ if query_embedding.size == 0:
343
+ return []
344
+
345
+ # Считаем косинусную близость
346
+ similarities = cosine_similarity(query_embedding, entity_embeddings)[0]
347
+
348
+ # Получаем индексы топ-N сущностей
349
+ top_indices = np.argsort(similarities)[-top_n:][::-1]
350
+
351
+ # Фильтруем сущности, которые используются для поиска
352
+ search_df = entities_df.copy()
353
+ search_df = search_df[search_df['in_search_text'].notna()]
354
+
355
+ # Если после фильтрации нет данных, возвращаем пустой список
356
+ if search_df.empty:
357
+ return []
358
+
359
+ # Получаем топ-N сущностей
360
+ results = []
361
+
362
+ for idx in top_indices:
363
+ if idx >= len(search_df):
364
+ continue
365
+
366
+ entity = search_df.iloc[idx]
367
+ similarity = similarities[idx]
368
+
369
+ # Создаем результат
370
+ result = {
371
+ "id": entity["id"],
372
+ "name": entity["name"],
373
+ "text": entity["text"],
374
+ "type": entity["type"],
375
+ "score": float(similarity),
376
+ "doc_name": entity["doc_name"],
377
+ "metadata": entity["metadata"]
378
+ }
379
+
380
+ results.append(result)
381
+
382
+ return results
383
+
384
+
385
+ @app.get("/search/entities", response_model=List[EntityResponse])
386
+ async def api_search_entities(
387
+ query: str = Query(..., description="Поисковый запрос"),
388
+ limit: int = Query(MAX_ENTITIES, description="Максимальное количество результатов")
389
+ ):
390
+ """
391
+ Эндпоинт для поиска сущностей по запросу.
392
+
393
+ Args:
394
+ query: Поисковый запрос
395
+ limit: Максимальное количество результатов
396
+
397
+ Returns:
398
+ Список найденных сущностей
399
+ """
400
+ results = search_entities(query, limit)
401
+ return results
402
+
403
+
404
+ @app.get("/search/text", response_model=TextResponse)
405
+ async def api_search_text(
406
+ query: str = Query(..., description="Поисковый запрос"),
407
+ limit: int = Query(MAX_ENTITIES, description="Максимальное количество учитываемых сущностей")
408
+ ):
409
+ """
410
+ Эндпоинт для поиска и сборки полного текста по запросу.
411
+
412
+ Args:
413
+ query: Поисковый запрос
414
+ limit: Максимальное количество учитываемых сущностей
415
+
416
+ Returns:
417
+ Собранный текст и количество использованных сущностей
418
+ """
419
+ global injection_builder
420
+
421
+ # Проверяем наличие сборщика инъекций
422
+ if injection_builder is None:
423
+ logging.error("Сборщик инъекций не инициализирован.")
424
+ return {"text": "", "entities_count": 0}
425
+
426
+ # Получаем найденные сущности
427
+ entity_results = search_entities(query, limit)
428
+
429
+ if not entity_results:
430
+ return {"text": "", "entities_count": 0}
431
+
432
+ # Получаем список ID сущностей
433
+ entity_ids = [str(result["id"]) for result in entity_results]
434
+
435
+ # Собираем текст, используя напрямую ID
436
+ try:
437
+ assembled_text = injection_builder.build(entity_ids)
438
+ print('Всё ок прошло вроде бы')
439
+ return {"text": assembled_text, "entities_count": len(entity_ids)}
440
+ except ImportError as e:
441
+ # Обработка ошибки импорта модулей для работы с изображениями
442
+ logging.error(f"Ошибка импорта при сборке текста: {e}")
443
+ # Альтернативная сборка текста без использования injection_builder
444
+ simple_text = "\n\n".join([result["text"] for result in entity_results if result.get("text")])
445
+ return {"text": simple_text, "entities_count": len(entity_ids)}
446
+ except Exception as e:
447
+ logging.error(f"Ошибка при сборке текста: {e}")
448
+ return {"text": "", "entities_count": 0}
449
+
450
+
451
+ @app.get("/search/texts", response_model=TextsResponse)
452
+ async def api_search_texts(
453
+ query: str = Query(..., description="Поисковый запрос"),
454
+ limit: int = Query(MAX_ENTITIES, description="Максимальное количество результатов")
455
+ ):
456
+ """
457
+ Эндпоинт для поиска списка текстов сущностей по запросу.
458
+
459
+ Args:
460
+ query: Поисковый запрос
461
+ limit: Максимальное количество результатов
462
+
463
+ Returns:
464
+ Список текстов найденных сущностей и их количество
465
+ """
466
+ # Получаем найденные сущности
467
+ entity_results = search_entities(query, limit)
468
+
469
+ if not entity_results:
470
+ return {"texts": [], "entities_count": 0}
471
+
472
+ # Извлекаем тексты из результатов
473
+ texts = [result["text"] for result in entity_results if result.get("text")]
474
+
475
+ return {"texts": texts, "entities_count": len(texts)}
476
+
477
+
478
+ @app.get("/search/text_test", response_model=TextResponse)
479
+ async def api_search_text_test(
480
+ query: str = Query(..., description="Поисковый запрос"),
481
+ limit: int = Query(MAX_ENTITIES, description="Максимальное количество учитываемых сущностей")
482
+ ):
483
+ """
484
+ Тестовый эндпоинт для поиска и сборки текста с использованием подхода из test_chunking_visualization.py.
485
+
486
+ Args:
487
+ query: Поисковый запрос
488
+ limit: Максимальное количество учитываемых сущностей
489
+
490
+ Returns:
491
+ Собранный текст и количество использованных сущностей
492
+ """
493
+ global entity_repository, injection_builder
494
+
495
+ # Проверяем наличие репозитория и сборщика инъекций
496
+ if entity_repository is None or injection_builder is None:
497
+ logging.error("Репозиторий или сборщик инъекций не инициализированы.")
498
+ return {"text": "", "entities_count": 0}
499
+
500
+ # Получаем найденные сущности
501
+ entity_results = search_entities(query, limit)
502
+
503
+ if not entity_results:
504
+ return {"text": "", "entities_count": 0}
505
+
506
+ try:
507
+ # Получаем объекты сущностей из репозитория по ID
508
+ entity_ids = [result["id"] for result in entity_results]
509
+ entities = []
510
+
511
+ for entity_id in entity_ids:
512
+ entity = entity_repository.get_entity_by_id(entity_id)
513
+ if entity:
514
+ entities.append(entity)
515
+
516
+ logging.info(f"Найдено {len(entities)} объектов сущностей по ID")
517
+
518
+ if not entities:
519
+ logging.error("Не удалось найти сущности в репозитории")
520
+ # Собираем простой текст из результатов поиска
521
+ simple_text = "\n\n".join([result["text"] for result in entity_results if result.get("text")])
522
+ return {"text": simple_text, "entities_count": len(entity_results)}
523
+
524
+ # Собираем текст, как в test_chunking_visualization.py
525
+ assembled_text = injection_builder.build(entities) # Передаем сами объекты
526
+
527
+ return {"text": assembled_text, "entities_count": len(entities)}
528
+ except Exception as e:
529
+ logging.error(f"Ошибка при сборке текста: {e}", exc_info=True)
530
+ # Запасной вариант - просто соединяем тексты
531
+ fallback_text = "\n\n".join([result["text"] for result in entity_results if result.get("text")])
532
+ return {"text": fallback_text, "entities_count": len(entity_results)}
533
+
534
+
535
+ def save_entities_to_csv(entities: List[LinkerEntity], csv_path: str) -> None:
536
+ """
537
+ Сохраняет сущности в CSV файл.
538
+
539
+ Args:
540
+ entities: Список сущностей
541
+ csv_path: Путь для сохранения CSV файла
542
+ """
543
+ logging.info(f"Сохранение {len(entities)} сущностей в {csv_path}")
544
+
545
+ # Создаем директорию, если она не существует
546
+ os.makedirs(os.path.dirname(csv_path), exist_ok=True)
547
+
548
+ # Преобразуем сущности в DataFrame и сохраняем
549
+ df = entities_to_dataframe(entities)
550
+ df.to_csv(csv_path, index=False)
551
+
552
+ logging.info(f"Сохранено {len(entities)} сущностей в {csv_path}")
553
+
554
+
555
+ def load_entities_from_csv(csv_path: str) -> List[LinkerEntity]:
556
+ """
557
+ Загружает сущности из CSV файла.
558
+
559
+ Args:
560
+ csv_path: Путь к CSV файлу
561
+
562
+ Returns:
563
+ Список сущностей
564
+ """
565
+ logging.info(f"Загрузка сущностей из {csv_path}")
566
+
567
+ if not os.path.exists(csv_path):
568
+ logging.error(f"Файл {csv_path} не найден")
569
+ return []
570
+
571
+ df = pd.read_csv(csv_path)
572
+ entities = []
573
+
574
+ for _, row in df.iterrows():
575
+ # Обработка метаданных
576
+ metadata = row.get("metadata", {})
577
+ if isinstance(metadata, str):
578
+ try:
579
+ metadata = eval(metadata) if metadata and not pd.isna(metadata) else {}
580
+ except:
581
+ metadata = {}
582
+
583
+ # Общие поля для всех типов сущностей
584
+ common_args = {
585
+ "id": row["id"],
586
+ "name": row["name"] if not pd.isna(row.get("name", "")) else "",
587
+ "text": row["text"] if not pd.isna(row.get("text", "")) else "",
588
+ "metadata": metadata,
589
+ "type": row["type"],
590
+ }
591
+
592
+ # Добавляем in_search_text, если он есть
593
+ if "in_search_text" in row and not pd.isna(row["in_search_text"]):
594
+ common_args["in_search_text"] = row["in_search_text"]
595
+
596
+ # Добавляем поля связи, если они есть
597
+ if "source_id" in row and not pd.isna(row["source_id"]):
598
+ common_args["source_id"] = row["source_id"]
599
+ common_args["target_id"] = row["target_id"]
600
+ if "number_in_relation" in row and not pd.isna(row["number_in_relation"]):
601
+ common_args["number_in_relation"] = int(row["number_in_relation"])
602
+
603
+ entity = LinkerEntity(**common_args)
604
+ entities.append(entity)
605
+
606
+ logging.info(f"Загружено {len(entities)} сущностей из {csv_path}")
607
+ return entities
608
+
609
+
610
+ def save_embeddings(embeddings: np.ndarray, file_path: str) -> None:
611
+ """
612
+ Сохраняет эмбеддинги в numpy файл.
613
+
614
+ Args:
615
+ embeddings: Массив эмбеддингов
616
+ file_path: Путь для сохранения файла
617
+ """
618
+ logging.info(f"Сохранение эмбеддингов размером {embeddings.shape} в {file_path}")
619
+
620
+ # Создаем директорию, если она не существует
621
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
622
+
623
+ # Сохраняем эмбеддинги
624
+ np.save(file_path, embeddings)
625
+
626
+ logging.info(f"Эмбеддинги сохранены в {file_path}")
627
+
628
+
629
+ def load_embeddings(file_path: str) -> np.ndarray:
630
+ """
631
+ Загружает эмбеддинги из numpy файла.
632
+
633
+ Args:
634
+ file_path: Путь к файлу
635
+
636
+ Returns:
637
+ Массив эмбеддингов
638
+ """
639
+ logging.info(f"Загрузка эмбеддингов из {file_path}")
640
+
641
+ if not os.path.exists(file_path):
642
+ logging.error(f"Файл {file_path} не найден")
643
+ return np.array([])
644
+
645
+ embeddings = np.load(file_path)
646
+
647
+ logging.info(f"Загружены эмбеддинги размером {embeddings.shape}")
648
+ return embeddings
649
+
650
+
651
+ def prepare_data():
652
+ """
653
+ Подготавливает все необходимые данные для API.
654
+ """
655
+ global entities_df, entity_embeddings, entity_repository, injection_builder
656
+
657
+ # Проверяем наличие кэшированных данных
658
+ cache_exists = os.path.exists(ENTITIES_CSV) and os.path.exists(EMBEDDINGS_NPY)
659
+
660
+ if cache_exists:
661
+ logging.info("Найдены кэшированные данные, загружаем их")
662
+
663
+ # Загружаем сущности из CSV
664
+ entities = load_entities_from_csv(ENTITIES_CSV)
665
+
666
+ if not entities:
667
+ logging.error("Не удалось загрузить сущности из кэша, генерируем заново")
668
+ cache_exists = False
669
+ else:
670
+ # Преобразуем сущности в DataFrame
671
+ entities_df = entities_to_dataframe(entities)
672
+
673
+ # Загружаем эмбеддинги
674
+ entity_embeddings = load_embeddings(EMBEDDINGS_NPY)
675
+
676
+ if entity_embeddings.size == 0:
677
+ logging.error("Не удалось загрузить эмбеддинги из кэша, генерируем заново")
678
+ cache_exists = False
679
+ else:
680
+ # Инициализируем хранилище и сборщик
681
+ init_entity_repository_and_builder(entities)
682
+ logging.info("Данные успешно загружены из кэша")
683
+
684
+ # Если кэшированных данных нет или их не удалось загрузить, генерируем заново
685
+ if not cache_exists:
686
+ logging.info("Кэшированные данные не найдены или не могут быть загружены, обрабатываем документы")
687
+
688
+ # Загружаем и обрабатываем документы
689
+ documents = load_documents(DOCS_FOLDER)
690
+
691
+ if not documents:
692
+ logging.error(f"Не найдено документов в папке {DOCS_FOLDER}")
693
+ return
694
+
695
+ # Получаем сущности из всех документов
696
+ all_entities = process_documents(documents)
697
+
698
+ if not all_entities:
699
+ logging.error("Не получено сущностей из документов")
700
+ return
701
+
702
+ # Преобразуем сущности в DataFrame
703
+ entities_df = entities_to_dataframe(all_entities)
704
+
705
+ # Инициализируем хранилище и сборщик
706
+ init_entity_repository_and_builder(all_entities)
707
+
708
+ # Фильтруем только сущности для поиска
709
+ search_df = entities_df[entities_df['in_search_text'].notna()]
710
+
711
+ if search_df.empty:
712
+ logging.error("Нет сущностей для поиска с in_search_text")
713
+ return
714
+
715
+ # Векторизуем тексты сущностей
716
+ search_texts = search_df['in_search_text'].tolist()
717
+ entity_embeddings = get_embeddings(search_texts)
718
+
719
+ logging.info(f"Подготовлено {len(search_df)} сущностей для поиска")
720
+ logging.info(f"Размер эмбеддингов: {entity_embeddings.shape}")
721
+
722
+ # Сохраняем данные в кэш для последующего использования
723
+ save_entities_to_csv(all_entities, ENTITIES_CSV)
724
+ save_embeddings(entity_embeddings, EMBEDDINGS_NPY)
725
+ logging.info("Данные сохранены в кэш для последующего использования")
726
+
727
+ # Вывод итоговой информации (независимо от источника данных)
728
+ logging.info(f"Подготовка данных завершена. Готово к использованию {entity_embeddings.shape[0]} сущностей")
729
+
730
+
731
+ @app.on_event("startup")
732
+ async def startup_event():
733
+ """Запускается при старте приложения."""
734
+ setup_logging()
735
+ prepare_data()
736
+
737
+
738
+ def main():
739
+ """Основная функция для запуска скрипта вручную."""
740
+ setup_logging()
741
+ prepare_data()
742
+
743
+ # Запуск Uvicorn сервера
744
+ uvicorn.run(app, host="0.0.0.0", port=8017)
745
+
746
+
747
+ if __name__ == "__main__":
748
+ main()
lib/extractor/scripts/test_chunking_visualization.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Скрипт для визуального тестирования процесса чанкинга и сборки документа.
4
+
5
+ Этот скрипт:
6
+ 1. Считывает test_input/test.docx с помощью UniversalParser
7
+ 2. Чанкит документ через Destructurer с fixed_size-стратегией
8
+ 3. Сохраняет результат чанкинга в test_output/test.csv
9
+ 4. Выбирает 20-30 случайных чанков из CSV
10
+ 5. Создает InjectionBuilder с InMemoryEntityRepository
11
+ 6. Собирает текст из выбранных чанков
12
+ 7. Сохраняет результат в test_output/test_builded.txt
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ import random
18
+ from pathlib import Path
19
+ from typing import List
20
+
21
+ import pandas as pd
22
+ from ntr_fileparser import UniversalParser
23
+
24
+ from ntr_text_fragmentation.chunking.specific_strategies.fixed_size_chunking import \
25
+ FixedSizeChunkingStrategy
26
+ from ntr_text_fragmentation.core.destructurer import Destructurer
27
+ from ntr_text_fragmentation.core.entity_repository import \
28
+ InMemoryEntityRepository
29
+ from ntr_text_fragmentation.core.injection_builder import InjectionBuilder
30
+ from ntr_text_fragmentation.models.linker_entity import LinkerEntity
31
+
32
+
33
+ def setup_logging() -> None:
34
+ """Настройка логгирования."""
35
+ logging.basicConfig(
36
+ level=logging.INFO,
37
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
38
+ )
39
+
40
+
41
+ def ensure_directories() -> None:
42
+ """Проверка наличия необходимых директорий."""
43
+ for directory in ["test_input", "test_output"]:
44
+ Path(directory).mkdir(parents=True, exist_ok=True)
45
+
46
+
47
+ def save_entities_to_csv(entities: List[LinkerEntity], csv_path: str) -> None:
48
+ """
49
+ Сохраняет сущности в CSV файл.
50
+
51
+ Args:
52
+ entities: Список сущностей
53
+ csv_path: Путь для сохранения CSV файла
54
+ """
55
+ data = []
56
+ for entity in entities:
57
+ # Базовые поля для всех типов сущностей
58
+ entity_dict = {
59
+ "id": str(entity.id),
60
+ "type": entity.type,
61
+ "name": entity.name,
62
+ "text": entity.text,
63
+ "metadata": str(entity.metadata),
64
+ "in_search_text": entity.in_search_text,
65
+ "source_id": entity.source_id,
66
+ "target_id": entity.target_id,
67
+ "number_in_relation": entity.number_in_relation,
68
+ }
69
+
70
+ data.append(entity_dict)
71
+
72
+ df = pd.DataFrame(data)
73
+ df.to_csv(csv_path, index=False)
74
+ logging.info(f"Сохранено {len(entities)} сущностей в {csv_path}")
75
+
76
+
77
+ def load_entities_from_csv(csv_path: str) -> List[LinkerEntity]:
78
+ """
79
+ Загружает сущности из CSV файла.
80
+
81
+ Args:
82
+ csv_path: Путь к CSV файлу
83
+
84
+ Returns:
85
+ Список сущностей
86
+ """
87
+ df = pd.read_csv(csv_path)
88
+ entities = []
89
+
90
+ for _, row in df.iterrows():
91
+ # Обработка метаданных
92
+ metadata_str = row.get("metadata", "{}")
93
+ try:
94
+ metadata = (
95
+ eval(metadata_str) if metadata_str and not pd.isna(metadata_str) else {}
96
+ )
97
+ except:
98
+ metadata = {}
99
+
100
+ # Общие поля для всех типов сущностей
101
+ common_args = {
102
+ "id": row["id"],
103
+ "name": row["name"] if not pd.isna(row.get("name", "")) else "",
104
+ "text": row["text"] if not pd.isna(row.get("text", "")) else "",
105
+ "metadata": metadata,
106
+ "in_search_text": row["in_search_text"],
107
+ "type": row["type"],
108
+ }
109
+
110
+ # Добавляем поля связи, если они есть
111
+ if not pd.isna(row.get("source_id", "")):
112
+ common_args["source_id"] = row["source_id"]
113
+ common_args["target_id"] = row["target_id"]
114
+ if not pd.isna(row.get("number_in_relation", "")):
115
+ common_args["number_in_relation"] = int(row["number_in_relation"])
116
+
117
+ entity = LinkerEntity(**common_args)
118
+ entities.append(entity)
119
+
120
+ logging.info(f"Загружено {len(entities)} сущностей из {csv_path}")
121
+ return entities
122
+
123
+
124
+ def main() -> None:
125
+ """Основная функция скрипта."""
126
+ setup_logging()
127
+ ensure_directories()
128
+
129
+ # Пути к файлам
130
+ input_doc_path = "test_input/test.docx"
131
+ output_csv_path = "test_output/test.csv"
132
+ output_text_path = "test_output/test_builded.txt"
133
+
134
+ # Проверка наличия входного файла
135
+ if not os.path.exists(input_doc_path):
136
+ logging.error(f"Файл {input_doc_path} не найден!")
137
+ return
138
+
139
+ logging.info(f"Парсинг документа {input_doc_path}")
140
+
141
+ try:
142
+ # Шаг 1: Парсинг документа дважды, как если бы это были два разных документа
143
+ parser = UniversalParser()
144
+ document1 = parser.parse_by_path(input_doc_path)
145
+ document2 = parser.parse_by_path(input_doc_path)
146
+
147
+ # Меняем название второго документа, чтобы отличить его
148
+ document2.name = document2.name + "_copy" if document2.name else "copy_doc"
149
+
150
+ # Шаг 2: Чанкинг обоих документов с использованием fixed_size-стратегии
151
+ all_entities = []
152
+
153
+ # Обработка первого документа
154
+ destructurer1 = Destructurer(
155
+ document1, strategy_name="fixed_size", words_per_chunk=50, overlap_words=25
156
+ )
157
+ logging.info("Начало процесса чанкинга первого документа")
158
+ entities1 = destructurer1.destructure()
159
+
160
+ # Добавляем метаданные о документе к каждой сущности
161
+ for entity in entities1:
162
+ if not hasattr(entity, 'metadata') or entity.metadata is None:
163
+ entity.metadata = {}
164
+ entity.metadata['doc_name'] = "document1"
165
+
166
+ logging.info(f"Получено {len(entities1)} сущностей из первого документа")
167
+ all_entities.extend(entities1)
168
+
169
+ # Обработка второго документа
170
+ destructurer2 = Destructurer(
171
+ document2, strategy_name="fixed_size", words_per_chunk=50, overlap_words=25
172
+ )
173
+ logging.info("Начало процесса чанкинга второго документа")
174
+ entities2 = destructurer2.destructure()
175
+
176
+ # Добавляем метаданные о документе к каждой сущности
177
+ for entity in entities2:
178
+ if not hasattr(entity, 'metadata') or entity.metadata is None:
179
+ entity.metadata = {}
180
+ entity.metadata['doc_name'] = "document2"
181
+
182
+ logging.info(f"Получено {len(entities2)} сущностей из второго документа")
183
+ all_entities.extend(entities2)
184
+
185
+ logging.info(f"Всего получено {len(all_entities)} сущностей из обоих документов")
186
+
187
+ # Шаг 3: Сохранение результатов чанкинга в CSV
188
+ save_entities_to_csv(all_entities, output_csv_path)
189
+
190
+ # Шаг 4: Загрузка сущностей из CSV и выбор случайных чанков
191
+ loaded_entities = load_entities_from_csv(output_csv_path)
192
+
193
+ # Фильтрация только чанков
194
+ chunks = [e for e in loaded_entities if e.in_search_text is not None]
195
+
196
+ # Выбор случайных чанков (от 20 до 30)
197
+ num_chunks_to_select = min(random.randint(20, 30), len(chunks))
198
+ selected_chunks = random.sample(chunks, num_chunks_to_select)
199
+
200
+ logging.info(f"Выбрано {len(selected_chunks)} случайных чанков для сборки")
201
+
202
+ # Дополнительная статистика по документам
203
+ doc1_chunks = [c for c in selected_chunks if hasattr(c, 'metadata') and c.metadata.get('doc_name') == "document1"]
204
+ doc2_chunks = [c for c in selected_chunks if hasattr(c, 'metadata') and c.metadata.get('doc_name') == "document2"]
205
+ logging.info(f"Из них {len(doc1_chunks)} чанков из первого документа и {len(doc2_chunks)} из второго")
206
+
207
+ # Шаг 5: Создание InjectionBuilder с InMemoryEntityRepository
208
+ repository = InMemoryEntityRepository(loaded_entities)
209
+ builder = InjectionBuilder(repository=repository)
210
+
211
+ # Регистрация стратегии
212
+ builder.register_strategy("fixed_size", FixedSizeChunkingStrategy)
213
+
214
+ # Шаг 6: Сборка текста из выбранных чанков
215
+ logging.info("Начало сборки текста из выбранных чанков")
216
+ assembled_text = builder.build(selected_chunks)
217
+
218
+ # Шаг 7: Сохранение результата в файл
219
+ with open(output_text_path, "w", encoding="utf-8") as f:
220
+ f.write(assembled_text)
221
+
222
+ logging.info(f"Результат сборки сохранен в {output_text_path}")
223
+
224
+ # Вывод статистики
225
+ logging.info(f"Общее количество сущностей: {len(loaded_entities)}")
226
+ logging.info(f"Количество чанков: {len(chunks)}")
227
+ logging.info(f"Выбрано для сборки: {len(selected_chunks)}")
228
+ logging.info(f"Длина собранного текста: {len(assembled_text)} символов")
229
+
230
+ except Exception as e:
231
+ logging.error(f"П��оизошла ошибка: {e}", exc_info=True)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()
lib/extractor/tests/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Пакет с тестами для ntr_text_fragmentation.
3
+ """
lib/extractor/tests/chunking/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Тесты для компонентов чанкинга.
3
+ """