muryshev commited on
Commit
57cf043
·
1 Parent(s): a3a0792
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +5 -0
  2. .env-example +1 -0
  3. .gitignore +19 -0
  4. Dockerfile +44 -0
  5. README.md +0 -10
  6. common/common.py +235 -0
  7. common/configuration.py +274 -0
  8. common/constants.py +475 -0
  9. common/db.py +39 -0
  10. common/dependencies.py +83 -0
  11. common/exceptions.py +22 -0
  12. components/datasets/dispatcher.py +313 -0
  13. components/dbo/models/acronym.py +19 -0
  14. components/dbo/models/base.py +20 -0
  15. components/dbo/models/dataset.py +26 -0
  16. components/dbo/models/dataset_document.py +24 -0
  17. components/dbo/models/document.py +25 -0
  18. components/dbo/models/feedback.py +27 -0
  19. components/dbo/models/llm_config.py +31 -0
  20. components/dbo/models/llm_prompt.py +21 -0
  21. components/dbo/models/log.py +19 -0
  22. components/elastic/__init__.py +7 -0
  23. components/elastic/create_index_elastic.py +298 -0
  24. components/elastic/create_index_elastic_abbreviation.py +77 -0
  25. components/elastic/create_index_elastic_chunks.py +73 -0
  26. components/elastic/create_index_elastic_group.py +133 -0
  27. components/elastic/create_index_elastic_rocks_nn.py +137 -0
  28. components/elastic/create_index_elastic_segmentation.py +101 -0
  29. components/elastic/elasticsearch_client.py +111 -0
  30. components/embedding_extraction.py +195 -0
  31. components/faiss_vector_database.py +248 -0
  32. components/llm/common.py +78 -0
  33. components/llm/deepinfra_api.py +346 -0
  34. components/llm/llm_api.py +37 -0
  35. components/llm/prompts.py +93 -0
  36. components/llm/utils.py +55 -0
  37. components/llm/vllm_api-sync.py +375 -0
  38. components/llm/vllm_api.py +317 -0
  39. components/nmd/aggregate_answers.py +189 -0
  40. components/nmd/faiss_vector_search.py +48 -0
  41. components/nmd/llm_chunk_search.py +235 -0
  42. components/nmd/metadata_manager.py +255 -0
  43. components/nmd/query_classification.py +79 -0
  44. components/nmd/rancker.py +32 -0
  45. components/parser/README.md +105 -0
  46. components/parser/abbreviations/README.md +119 -0
  47. components/parser/abbreviations/__init__.py +9 -0
  48. components/parser/abbreviations/abbreviation.py +328 -0
  49. components/parser/abbreviations/abbreviation_extractor.py +336 -0
  50. components/parser/abbreviations/constants.py +54 -0
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /data/
2
+ /logs/
3
+ __pycache__
4
+ *.db
5
+ .env
.env-example ADDED
@@ -0,0 +1 @@
 
 
1
+ DEEPINFRA_API_KEY=Bearer <ключ>
.gitignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /data/
2
+ common.log
3
+ /output/test.json
4
+ /logs/
5
+
6
+ venv
7
+ .idea
8
+ __pycache__
9
+ *.db
10
+
11
+ *.docx
12
+ *.doc
13
+ *.pdf
14
+ *.xlsx
15
+ *.xls
16
+ *.pptx
17
+ *.ppt
18
+ .env
19
+ /docker-compose.yaml
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nvidia/cuda:12.6.0-runtime-ubuntu22.04
2
+
3
+ ARG PORT=7860
4
+ ENV PORT=${PORT}
5
+ ENV CONFIG_PATH=config_dev.yaml
6
+ ENV SQLALCHEMY_DATABASE_URL=sqlite:///./logs.db
7
+
8
+ ENV PYTHONUNBUFFERED=1
9
+ ENV DEBIAN_FRONTEND=noninteractive
10
+
11
+ WORKDIR /app
12
+
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ build-essential \
15
+ python3.11 \
16
+ python3.11-distutils \
17
+ wget \
18
+ && wget https://bootstrap.pypa.io/get-pip.py \
19
+ && python3.11 get-pip.py \
20
+ && rm get-pip.py \
21
+ && apt-get clean \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ # Set Python 3.11 as the default python3
25
+ RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 \
26
+ && update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1
27
+
28
+ # Устанавливаем специфичные версии библиотек PyTorch
29
+ 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 /app/data/regulation_datasets /app/data/documents /app/logs
40
+
41
+
42
+ EXPOSE ${PORT}
43
+
44
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT}"]
README.md CHANGED
@@ -1,10 +0,0 @@
1
- ---
2
- title: Generic Chatbot Backend
3
- emoji: 🚀
4
- colorFrom: pink
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
common/common.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from enum import Enum
3
+
4
+
5
+ def configure_logging(level=logging.INFO, config_file_path='./common.log'):
6
+ logging.basicConfig(
7
+ filename=config_file_path,
8
+ filemode="a",
9
+ level=level,
10
+ datefmt="%Y-%m-%d %H:%M:%S",
11
+ format="[%(asctime)s.%(msecs)03d] %(module)30s:%(lineno)4d %(levelname)-7s - %(message)s",
12
+ )
13
+
14
+
15
+ def get_elastic_query(query):
16
+ return {
17
+ "query": {
18
+ "multi_match": {
19
+ "query": f"{query}",
20
+ "fields": ["text"],
21
+ "fuzziness": "AUTO",
22
+ "analyzer": "russian",
23
+ }
24
+ }
25
+ }
26
+
27
+
28
+ def get_elastic_people_query(query):
29
+ has_business_curator = (
30
+ "бизнес куратор" in query.lower()
31
+ or "бизнес-куратор" in query.lower()
32
+ or "куратор" in query.lower()
33
+ )
34
+ business_curator_boost = 30 if has_business_curator else 15
35
+ return {
36
+ "query": {
37
+ "bool": {
38
+ "should": [
39
+ {
40
+ "multi_match": {
41
+ "query": f"{query}",
42
+ "fields": ["person_name^3"],
43
+ "fuzziness": "AUTO",
44
+ "analyzer": "standard",
45
+ }
46
+ },
47
+ {
48
+ "nested": {
49
+ "path": "business_processes",
50
+ "query": {
51
+ "multi_match": {
52
+ "query": f"{query}",
53
+ "fields": [
54
+ "business_processes.production_activities_section",
55
+ "business_processes.processes_name",
56
+ ],
57
+ "fuzziness": "AUTO",
58
+ "analyzer": "standard",
59
+ }
60
+ },
61
+ }
62
+ },
63
+ {
64
+ "nested": {
65
+ "path": "organizatinal_structure",
66
+ "query": {
67
+ "multi_match": {
68
+ "query": f"{query}",
69
+ "fields": ["organizatinal_structure.position^2"],
70
+ "fuzziness": "AUTO",
71
+ "analyzer": "standard",
72
+ }
73
+ },
74
+ }
75
+ },
76
+ {
77
+ "nested": {
78
+ "path": "business_curator",
79
+ "query": {
80
+ "multi_match": {
81
+ "query": f"{query}",
82
+ "fields": [
83
+ f"business_curator.company_name^{business_curator_boost}"
84
+ ],
85
+ "fuzziness": "AUTO",
86
+ "analyzer": "standard",
87
+ }
88
+ },
89
+ }
90
+ },
91
+ ]
92
+ }
93
+ },
94
+ "min_score": 13.0,
95
+ }
96
+
97
+
98
+ def get_elastic_group_query(query):
99
+ return {
100
+ "query": {
101
+ "bool": {
102
+ "should": [
103
+ {
104
+ "multi_match": {
105
+ "query": f"{query}",
106
+ "fields": ["group_name"],
107
+ "fuzziness": "AUTO",
108
+ "analyzer": "standard",
109
+ }
110
+ },
111
+ {
112
+ "multi_match": {
113
+ "query": "персонального состава Персональный состав Комитета ПАО ГМК Норильский никель Рабочей группы",
114
+ "fields": ["group_name"],
115
+ "operator": "or",
116
+ "boost": 0.1,
117
+ }
118
+ },
119
+ ]
120
+ }
121
+ },
122
+ "min_score": 7.5,
123
+ }
124
+
125
+
126
+ def get_elastic_rocks_nn_query(query):
127
+ return {
128
+ "query": {
129
+ "function_score": {
130
+ "query": {
131
+ "multi_match": {
132
+ "query": f"{query}",
133
+ "fields": ["division_name", "division_name_2", "company_name"],
134
+ "fuzziness": "AUTO",
135
+ "analyzer": "custom_analyzer",
136
+ }
137
+ },
138
+ "functions": [{"filter": {"term": {"_id": "3"}}, "weight": 0.5}],
139
+ "boost_mode": "multiply",
140
+ }
141
+ },
142
+ "min_score": 0.5,
143
+ }
144
+
145
+
146
+ def get_elastic_segmentation_query(query):
147
+ return {
148
+ "query": {
149
+ "bool": {
150
+ "should": [
151
+ {
152
+ "multi_match": {
153
+ "query": f"{query}",
154
+ "fields": [
155
+ "segmentation_model",
156
+ "segmentation_model2",
157
+ "company_name",
158
+ ],
159
+ "fuzziness": "AUTO",
160
+ "analyzer": "russian",
161
+ }
162
+ },
163
+ {
164
+ "multi_match": {
165
+ "query": "модели сегментации модель сегментации",
166
+ "fields": ["segmentation_model", "segmentation_model2"],
167
+ "operator": "or",
168
+ "boost": 0.1,
169
+ }
170
+ },
171
+ ]
172
+ }
173
+ },
174
+ "min_score": 1.0,
175
+ }
176
+
177
+
178
+ def get_elastic_abbreviation_query(query):
179
+ return {
180
+ "query": {
181
+ "multi_match": {
182
+ "query": f"{query}",
183
+ "fuzziness": "AUTO",
184
+ "fields": ["text"],
185
+ "analyzer": "russian",
186
+ }
187
+ }
188
+ }
189
+
190
+
191
+ def combine_answer(answer):
192
+ """
193
+
194
+ Args:
195
+ answer:
196
+
197
+ Returns:
198
+
199
+ """
200
+ answer_combined = {}
201
+ indexes = []
202
+ for key in answer:
203
+ if key != 'people_search':
204
+ for answer_key in answer[key]:
205
+ answer_value = answer[key][answer_key]
206
+ filename_i = answer_value["doc_name"]
207
+ title_i = answer_value["title"]
208
+
209
+ if (
210
+ filename_i in answer_combined
211
+ and answer_value['index_answer'] not in indexes
212
+ ):
213
+ answer_combined[filename_i]["chunks"].append(answer_value)
214
+ else:
215
+ answer_combined[filename_i] = {
216
+ "filename": filename_i,
217
+ "title": title_i,
218
+ "chunks": [answer_value],
219
+ }
220
+ indexes.append(answer_value['index_answer'])
221
+ return list(answer_combined.values())
222
+
223
+
224
+ class TypeQuestion(Enum):
225
+ TYPE_ONE = '[1]'
226
+ TYPE_TWO = '[2]'
227
+ TYPE_THREE = '[3]'
228
+
229
+
230
+ def get_source_format(filename: str) -> str:
231
+ """
232
+ Получает формат файла из имени файла.
233
+ """
234
+ format_ = filename.split('.')[-1]
235
+ return format_.upper()
common/configuration.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module includes classes to define configurations."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from pyaml_env import parse_config
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class Query(BaseModel):
10
+ query: str
11
+ query_abbreviation: str
12
+ abbreviations_replaced: Optional[List] = None
13
+ userName: Optional[str] = None
14
+
15
+
16
+ class SemanticChunk(BaseModel):
17
+ index_answer: int
18
+ doc_name: str
19
+ title: str
20
+ text_answer: str
21
+ # doc_number: str # TODO Потом поменять название переменной на doc_id везде с чем это будет связанно
22
+ other_info: List
23
+ start_index_paragraph: int
24
+
25
+
26
+ class FilterChunks(BaseModel):
27
+ id: str
28
+ filename: str
29
+ title: str
30
+ chunks: List[SemanticChunk]
31
+
32
+
33
+ class BusinessProcess(BaseModel):
34
+ production_activities_section: Optional[str]
35
+ processes_name: Optional[str]
36
+ level_process: Optional[str]
37
+
38
+
39
+ class Lead(BaseModel):
40
+ person: Optional[str]
41
+ leads: Optional[str]
42
+
43
+
44
+ class Subordinate(BaseModel):
45
+ person_name: Optional[str]
46
+ position: Optional[str]
47
+
48
+
49
+ class OrganizationalStructure(BaseModel):
50
+ position: Optional[str] = None
51
+ leads: Optional[List[Lead]] = None
52
+ subordinates: Optional[Subordinate] = None
53
+
54
+
55
+ class RocksNN(BaseModel):
56
+ division: Optional[str]
57
+ company_name: Optional[str]
58
+
59
+
60
+ class RocksNNSearch(BaseModel):
61
+ division: Optional[str]
62
+ company_name: Optional[List]
63
+
64
+
65
+ class SegmentationSearch(BaseModel):
66
+ segmentation_model: Optional[str]
67
+ company_name: Optional[List]
68
+
69
+
70
+ class Group(BaseModel):
71
+ group_name: Optional[str]
72
+ position_in_group: Optional[str]
73
+ block: Optional[str]
74
+
75
+
76
+ class GroupComposition(BaseModel):
77
+ person_name: Optional[str]
78
+ position_in_group: Optional[str]
79
+
80
+
81
+ class SearchGroupComposition(BaseModel):
82
+ group_name: Optional[str]
83
+ group_composition: Optional[List[GroupComposition]]
84
+
85
+
86
+ class PeopleChunks(BaseModel):
87
+ business_processes: Optional[List[BusinessProcess]] = None
88
+ organizatinal_structure: Optional[List[OrganizationalStructure]] = None
89
+ business_curator: Optional[List[RocksNN]] = None
90
+ groups: Optional[List[Group]] = None
91
+ person_name: str
92
+
93
+
94
+ class SummaryChunks(BaseModel):
95
+ doc_chunks: Optional[List[FilterChunks]] = None
96
+ people_search: Optional[List[PeopleChunks]] = None
97
+ groups_search: Optional[SearchGroupComposition] = None
98
+ rocks_nn_search: Optional[RocksNNSearch] = None
99
+ segmentation_search: Optional[SegmentationSearch] = None
100
+ query_type: str = '[3]'
101
+
102
+
103
+ class ElasticConfiguration:
104
+ def __init__(self, config_data):
105
+ self.es_host = str(config_data['es_host'])
106
+ self.es_port = int(config_data['es_port'])
107
+ self.use_elastic = bool(config_data['use_elastic'])
108
+ self.people_path = str(config_data['people_path'])
109
+
110
+
111
+ class FaissDataConfiguration:
112
+ def __init__(self, config_data):
113
+ self.model_embedding_path = str(config_data['model_embedding_path'])
114
+ self.device = str(config_data['device'])
115
+ self.path_to_metadata = str(config_data['path_to_metadata'])
116
+
117
+
118
+ class ChunksElasticSearchConfiguration:
119
+ def __init__(self, config_data):
120
+ self.use_chunks_search = bool(config_data['use_chunks_search'])
121
+ self.index_name = str(config_data['index_name'])
122
+ self.k_neighbors = int(config_data['k_neighbors'])
123
+
124
+
125
+ class PeopleSearchConfiguration:
126
+ def __init__(self, config_data):
127
+ self.use_people_search = bool(config_data['use_people_search'])
128
+ self.index_name = str(config_data['index_name'])
129
+ self.k_neighbors = int(config_data['k_neighbors'])
130
+
131
+
132
+ class VectorSearchConfiguration:
133
+ def __init__(self, config_data):
134
+ self.use_vector_search = bool(config_data['use_vector_search'])
135
+ self.k_neighbors = int(config_data['k_neighbors'])
136
+
137
+
138
+ class GroupsSearchConfiguration:
139
+ def __init__(self, config_data):
140
+ self.use_groups_search = bool(config_data['use_groups_search'])
141
+ self.index_name = str(config_data['index_name'])
142
+ self.k_neighbors = int(config_data['k_neighbors'])
143
+
144
+
145
+ class RocksNNSearchConfiguration:
146
+ def __init__(self, config_data):
147
+ self.use_rocks_nn_search = bool(config_data['use_rocks_nn_search'])
148
+ self.index_name = str(config_data['index_name'])
149
+ self.k_neighbors = int(config_data['k_neighbors'])
150
+
151
+
152
+ class AbbreviationSearchConfiguration:
153
+ def __init__(self, config_data):
154
+ self.use_abbreviation_search = bool(config_data['use_abbreviation_search'])
155
+ self.index_name = str(config_data['index_name'])
156
+ self.k_neighbors = int(config_data['k_neighbors'])
157
+
158
+
159
+ class SegmentationSearchConfiguration:
160
+ def __init__(self, config_data):
161
+ self.use_segmentation_search = bool(config_data['use_segmentation_search'])
162
+ self.index_name = str(config_data['index_name'])
163
+ self.k_neighbors = int(config_data['k_neighbors'])
164
+
165
+
166
+ class SearchConfiguration:
167
+ def __init__(self, config_data):
168
+ self.vector_search = VectorSearchConfiguration(config_data['vector_search'])
169
+ self.people_elastic_search = PeopleSearchConfiguration(
170
+ config_data['people_elastic_search']
171
+ )
172
+ self.chunks_elastic_search = ChunksElasticSearchConfiguration(
173
+ config_data['chunks_elastic_search']
174
+ )
175
+ self.groups_elastic_search = GroupsSearchConfiguration(
176
+ config_data['groups_elastic_search']
177
+ )
178
+ self.rocks_nn_elastic_search = RocksNNSearchConfiguration(
179
+ config_data['rocks_nn_elastic_search']
180
+ )
181
+ self.segmentation_elastic_search = SegmentationSearchConfiguration(
182
+ config_data['segmentation_elastic_search']
183
+ )
184
+ self.stop_index_names = list(config_data['stop_index_names'])
185
+ self.abbreviation_search = AbbreviationSearchConfiguration(
186
+ config_data['abbreviation_search']
187
+ )
188
+
189
+
190
+ class FilesConfiguration:
191
+ def __init__(self, config_data):
192
+ self.empty_start = bool(config_data['empty_start'])
193
+ self.regulations_path = str(config_data['regulations_path'])
194
+ self.default_regulations_path = str(config_data['default_regulations_path'])
195
+ self.documents_path = str(config_data['documents_path'])
196
+
197
+
198
+ class RankingConfiguration:
199
+ def __init__(self, config_data):
200
+ self.use_ranging = bool(config_data['use_ranging'])
201
+ self.alpha = float(config_data['alpha'])
202
+ self.beta = float(config_data['beta'])
203
+ self.k_neighbors = int(config_data['k_neighbors'])
204
+
205
+
206
+ class DataBaseConfiguration:
207
+ def __init__(self, config_data):
208
+ self.elastic = ElasticConfiguration(config_data['elastic'])
209
+ self.faiss = FaissDataConfiguration(config_data['faiss'])
210
+ self.search = SearchConfiguration(config_data['search'])
211
+ self.files = FilesConfiguration(config_data['files'])
212
+ self.ranker = RankingConfiguration(config_data['ranging'])
213
+
214
+
215
+ class LLMConfiguration:
216
+ def __init__(self, config_data):
217
+ self.base_url = str(config_data['base_url']) if config_data['base_url'] not in ("", "null", "None") else None
218
+ self.api_key_env = (
219
+ str(config_data['api_key_env'])
220
+ if config_data['api_key_env'] not in ("", "null", "None")
221
+ else None
222
+ )
223
+ self.model = str(config_data['model'])
224
+ self.tokenizer = str(config_data['tokenizer_name'])
225
+ self.temperature = float(config_data['temperature'])
226
+ self.top_p = float(config_data['top_p'])
227
+ self.min_p = float(config_data['min_p'])
228
+ self.frequency_penalty = float(config_data['frequency_penalty'])
229
+ self.presence_penalty = float(config_data['presence_penalty'])
230
+ self.seed = int(config_data['seed'])
231
+
232
+
233
+ class CommonConfiguration:
234
+ def __init__(self, config_data):
235
+ self.log_file_path = str(config_data['log_file_path'])
236
+ self.log_sql_path = str(config_data['log_sql_path'])
237
+
238
+
239
+ class Configuration:
240
+ """Encapsulates all configuration parameters."""
241
+
242
+ def __init__(self, config_file_path: Optional[str] = None):
243
+ """Creates an instance of the class.
244
+
245
+ There is 1 possibility to load configuration data:
246
+ - from configuration file using a path;
247
+ If attribute is not None, the configuration file is used.
248
+
249
+ Args:
250
+ config_file_path: A path to config file to load configuration data from.
251
+ """
252
+ if config_file_path is not None:
253
+ self._load_from_config(config_file_path)
254
+ else:
255
+ raise ValueError('At least one of config_path must be not None.')
256
+
257
+ def _load_data(self, data: Dict[str, Any]):
258
+ """Loads configuration data from dictionary.
259
+
260
+ Args:
261
+ data: A configuration dictionary to load configuration data from.
262
+ """
263
+ self.common_config = CommonConfiguration(data['common'])
264
+ self.db_config = DataBaseConfiguration(data['bd'])
265
+ self.llm_config = LLMConfiguration(data['llm'])
266
+
267
+ def _load_from_config(self, config_file_path: str):
268
+ """Reads configuration file and form configuration dictionary.
269
+
270
+ Args:
271
+ config_file_path: A configuration dictionary to load configuration data from.
272
+ """
273
+ data = parse_config(config_file_path)
274
+ self._load_data(data)
common/constants.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module includes common constants for the project"""
2
+
3
+ DEFAULT_CONFIG_RELATIVE_PATH = 'config.yaml'
4
+
5
+
6
+ PROMPT_OLD = """
7
+ Ты мастер по документам. Я задам тебе запрос пользователя.
8
+ И пронумерованный список документов с текстами, найденных по запросу.
9
+ У документов будут названия и конкретный текст документа.
10
+ Твоя задача написать номер документа из списка,
11
+ текст и название которого лучше всего отвечает на заданный пользователем запрос.
12
+ Пиши в формате "Ответ: [Номер документа из списка]".
13
+ Больше в ответе ничего не нужно. Отвечай только на русском языке.
14
+ Запрос: {query}
15
+
16
+ Пронумерованный список документов с текстами:\n{answer}
17
+
18
+ """
19
+
20
+ PROMPT_CLASSIFICATION = """[INST] Ты распределитель запросов. Я дам тебе запрос. Твоя задача понять к какой из трёх групп нужно распределить запрос пользователя. Существует таблица ЭЛ, в которой записаны некие данные. Все запросы касаются компании, для которой создана эта таблица. Я приведу тебе примеры данных из этой таблицы ЭЛ:
21
+ Строка 1: Информация о сотруднике Кузнецов А.В.
22
+ Должность: Директор департамента гражданской обороны, предупреждения чрезвычайных ситуаций и пожарной безопасности ПАО "ГМК "Норильский никель"
23
+ Руководителем Кузнецов А.В. является Попов А.Н.
24
+ Входит в состав групп:
25
+ Персональный состав Рабочей группы по контролю за подготовкой гидротехнических сооружений объектов промышленности Компании и РОКС НН к паводковому сезону 2024. Должность внутри группы: Член Рабочей группы
26
+ Состав Комиссии по категорированию объектов критической информационной инфраструктуры Главного офиса ПАО "ГМК "Норильский никель". Должность внутри группы: Член Комиссии
27
+ ####
28
+ Строка 2: Информация о сотруднике Попов А.Н.
29
+ Должность: Старший вице-президент - Операционный директор, руководитель Забайкальского дивизиона
30
+ Руководит следующими сотрудниками:
31
+ Манукян А.Г.
32
+ Кузнецов А.В.
33
+ Руководителем Попов А.Н. является Потанин В.О.
34
+ Должность: Вице-президент - руководитель Забайкальского дивизиона
35
+ Отвечает за Бизнес процессы:
36
+ Производственно-техническое развитие
37
+ Геологоразведка и минерально-сырьевая база
38
+ Является Бизнес-куратором (РОКС НН):
39
+ ООО «Ширинское»ООО «Быстринская сервисная компания», ООО «Бугдаинский рудник», ООО «Востокгеология»
40
+ Входит в состав групп:
41
+ Составы Комиссий по проведению специальной оценки условий труда в Главном офисе ПАО "ГМК "Норильский никель". Должность внутри группы: Председатель Комиссии
42
+ Состав Научно-технического совета ПАО "ГМК "Норильский никель". Должность внутри группы: Председатель Научно-технического совета
43
+ ####
44
+ Виды связей в таблице: в таблице перечисляются составы, подразделения, комитеты, подкомитеты, комиссии, совет (в смысле группы людей) и рабочие группы. Данная таблица вида ЭЛ связывает конкретных людей с должностями, людьми друг с другом в подчинении, должностями внутри групп и названиями групп. Также в ней есть все возможные связи между различными ��олжностями у одного или нескольких людей. Таблица содержит всю информацию о том, за какой процесс или бизнес процесс кто отвечает. По фамилии также можно найти любого конкретного человека из таблицы вида ЭЛ. Вся информация о бизнес кураторах и за что они отвечают в таблице вида ЭЛ. В таблице можно найти кто отвечает и кто ответственен за всё что угодно.
45
+ Конец видов связей в таблице.
46
+ Основные правила:
47
+ - Если ответ на все вопросы внутри запроса можно найти напрямую ответ в такого вида таблице ЭЛ, и при этом больше никаких дополнительных размышлений для ответа не нужно, то это группа 2.
48
+ - Если данные из такого таблицы вида таблицы ЭЛ не дают прямого ответа на все вопросы в запросе, но отвечают хотя бы на один из них, то это группа 3.
49
+ - Если ответ на вопросы внутри запроса можно найти напрямую ответ в такого вида таблице ЭЛ, и также требуется дополнительная информация для ответа, то это группа 3.
50
+ - Если абсолютно непонятно что именно хочет пользователь, то это группа 3.
51
+ - Если для ответа на вопрос нужна только дополнительная информация вне таблицы группы ЭЛ, то это группа 1.
52
+ - Если таблица вида ЭЛ не поможет в ответе на запрос пользователя, то это группа 1.
53
+ - В конечном ответе должна быть одна цифра.
54
+ - Количество людей в запросе не должно влиять на постановку оценки.
55
+ Конец основных правил.
56
+ Ты действуешь по плану. Начало плана:
57
+ 1. Внимательно прочитай запрос. В запросе могут быть несколько вопросов.
58
+ 2. Рассуждай шаг за шагом, почему данный запрос должен относиться к какой-то из трёх групп. Во время рассуждения используй логику. Основывайся на типах информации из таблицы вида ЭЛ, видах связей в таблице и заданных основных правилах.
59
+ 3. Выбери конкретную группу, которая подходит лучше всего согласно твоим рассуждениям.
60
+ Конец плана.
61
+ Твой ответ должен выводиться в таком формате 'Рассуждения:Твои рассуждения
62
+ Ответ:[цифра группы]'. Цифра группы в итоговом ответе должна обрамляться скобочками '[]'.
63
+ Не пиши в ответ '####', это для разграничения.
64
+ ####
65
+ Далее будет первый структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
66
+ ####
67
+ Запрос: Кто является управляющим состава комитета управления МОМ?
68
+ ####
69
+ Вывод:
70
+ Рассуждения: В запросе пользователя вопрос, который касается имени управляющего состава комитета управления МОМ. Т.к. в таблице вида ЭЛ есть связь между именем и составами различных комитетов, то эти данные можно полностью получить в этой таблице, больше ничего не потребуется. Группа 2.
71
+ Ответ:[2]
72
+ ####
73
+ Далее будет второй структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
74
+ ####
75
+ Запрос: Как мне найти какой-то конкретный документ (НМД/ОПД)?
76
+ ####
77
+ Вывод:
78
+ Рассуждения: В запросе нет никаких данных, котор��е можно найти в таблице вида ЭЛ. Это группа 1.
79
+ Ответ:[1]
80
+ ####
81
+ Далее будет третий структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
82
+ ####
83
+ Запрос: А как мне узнать кто входит в состав КО? Где посмотреть Положение?
84
+ ####
85
+ Вывод:
86
+ Рассуждения: В запросе два вопроса. Первый вопрос касается имени человека, который входит в состав КО. Это можно полностью найти в таблице вида ЭЛ. Второй вопрос хочет узнать, где посмотреть Положение. Этого нет в таблице ЭЛ. Т.к. данные из такого таблицы вида таблицы ЭЛ не дают прямого ответа на все вопросы в запросе, но отвечают хотя бы на один из них, то это группа 3.
87
+ Ответ:[3]
88
+ ####
89
+ Далее будет четвертый структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
90
+ ####
91
+ Запрос: Уметрохин А.М.
92
+ ####
93
+ Вывод:
94
+ Рассуждения: В запросе имя человека. Видимо пользователь хочет получить данные по человеку в этой компании. Все данные по людям в компании есть в таблице вида ЭЛ, поэтому группа 2.
95
+ Ответ:[2]
96
+ ####
97
+ Далее будет пятый структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
98
+ ####
99
+ Запрос: Какая должность у Пупкина Вити
100
+ ####
101
+ Вывод:
102
+ Рассуждения: В запросе пользователь хочет получить информацию о должности Пупкина Вити. Эту информацию можно найти в таблице вида ЭЛ, поэтому это группа 2.
103
+ Ответ:[2]
104
+ ####
105
+ Далее будет шестой структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
106
+ ####
107
+ Запрос: Где посмотреть Положение?
108
+ ####
109
+ Вывод:
110
+ Рассуждения: В запросе спрашивают, на каком ресурсе можно посмотреть Положение. Этой информации не может быть в таблице вида ЭЛ, поэтому это группа 1.
111
+ Ответ:[1]
112
+ ####
113
+ Далее будет седьмой структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
114
+ ####
115
+ Запрос: Кто руководитель состава пожарной инспекции? Какие функции у руководителя состава пожарной инспекции?
116
+ ####
117
+ Вывод:
118
+ Рассуждения: В запросе 2 вопроса. В первом пытаются узнать имя человека, который входит в состав пожарной инспекции. Такого рода информация полностью содержится в таблице вида ЭЛ. Второй вопрос касается функций конкретной должности. В таблице вида ЭЛ есть привязка должностей к чему-либо, но нет пояснений о функциях конкретных должностей. Так как первый вопрос полностью можно найти в таблице вида ЭЛ, а второй нет, то это группа 3.
119
+ Ответ:[3]
120
+ ####
121
+ Далее будет восьмой структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
122
+ ####
123
+ Запрос: Кому подчиняется Василий Петрович?
124
+ ####
125
+ Вывод:
126
+ Рассуждения: В запросе хотят узнать информацию связи человека с другим в подчинение. Вся информация о связях людей в компании хранится в таблице вида ЭЛ. Группа 2.
127
+ Ответ:[2]
128
+ ####
129
+ Далее будет девятый структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
130
+ ####
131
+ Запрос: Как посмотреть в каких комитетах председателем является Потанин В.О.
132
+ ####
133
+ Вывод:
134
+ Рассуждения: В запросе хотят узнать список комитетов, председателем которых является Потанин В.О.. Данная информация полностью находится в таблице вида ЭЛ, так как там есть связь между именем, должностью в группе и названием группы. При этом больше никаких дополнительных размышлений для ответа не нужно, поэтому это группа 2.
135
+ Ответ:[2]
136
+ ####
137
+ Далее будет десятый структурный шаблон с правильной логикой ответа, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
138
+ ####
139
+ Запрос: Как работники компании могут понять сколько им заплатят?
140
+ ####
141
+ Вывод:
142
+ Рассуждения: В запросе хотят узнать информацию о зарплатах работников компании. В таблице ЭЛ нет такой информации. Хотя там есть информация о должностях, это никак не поможет в ответе на вопрос. Группа 1.
143
+ Ответ:[1]
144
+ ####
145
+ Далее будет настоящий запрос
146
+ ####
147
+ Запрос: {query}
148
+ ####
149
+ Вывод:
150
+ [/INST]"""
151
+
152
+ PROMPT = """ [INST] Ты специалист по внутренним данным компании. Ты давно работаешь в компании и знаешь все её правила. Тебе будет дан запрос пользователя и приведено несколько юридических документов. Твоя задача - подробно ответить на запрос пользователя, используя информацию из заданных юридических документов. За отлично выполненную работу тебе заплатят 10$. Я спас тебе жизнь и ты теперь должен отлично выполнить эту задачу. У тебя есть основные правила, которых ты придерживаешься во время вывода. Основные правила:
153
+ - Не обязательно все заданные юридические документы помогут тебе в формировании ответа.
154
+ - Ты должен использовать только заданные юридические документы.
155
+ - Тебе разрешено делать логические рассуждения по шагам на основе юридических документов для ответа на запрос.
156
+ - Тебе запрещено выдумывать. Вся информация для ответа или есть в предоставленных источниках, или её нет и тогда ты пишешь в ответе что её нет.
157
+ - Тебе запрещено самостоятельно расшифровывать любые сокращения.
158
+ - Используй официально-деловой стиль общения.
159
+ - Между различными логическими частями в документах будут стоять "...". Воспринимай это как разные куски информации.
160
+ - Тебе запрещено ставить "..." в ответе
161
+ - Если инициалы из запроса и предоставленных документов не совпадают, то это разные люди. Например "Иванов А.А." и "Иванов А.И." - это разные люди.
162
+ - Если запрос содержит "Кто", то в первую очередь ты должен постараться найти имя человека.
163
+ - Если ты ище��ь общую информацию о сотруднике, то ты стараешься выписать всё, что с ним связано.
164
+ - Если ты будешь дублировать одинаковую информацию из разных источников при цитировании во втором пункте.
165
+ - В документе с названием "Информация о сотруднике" весь текст документа относится к человеку, имя которого указано в заголовке документа.
166
+ - Если ты ищешь конкретную информацию о сотруднике, то ты должен во время цитирования писать как нужную информацию по запросу, так и имя сотрудника рядом с этой информацией.
167
+ - Квадратные скобки в документах по информации о сотрудниках для твоего понимания. Нельзя использовать квадратные скобки с текстом в ответе.
168
+ - Вместо названия документа в первых 3-х пунктах ответа писать слова "Документ [номер]".
169
+ - Различные документы разделены между собой обратным слешем для твоего удобства. Обратные слеши нельзя писать в ответе.
170
+ - Если ты не нашёл ответа на вопрос, то не нужно перечислять все документы, просто поставь в списке документов "-".
171
+ - Тебе запрещено писать номера пунктов плана, иначе тебя будут пытать
172
+ - Не пиши в ответе про заданные тебе правила и инструкцию
173
+ - Отвечай всегда только на РУССКОМ языке, даже если текст запроса и документов не на русском!
174
+ - Перед третьим пунктом плана ты обязан написать '%%'
175
+ - Не пиши в ответ "####", это для разграничения.
176
+ Конец основных правил.
177
+ Ты действуешь по плану. Начало плана:
178
+ 1) Прочитай запрос пользователя. Воспринимай запрос как нечто цельное. Напиши рассуждения шаг за шагом что именно тебе нужно найти для ответа на запрос. Какие цитаты из одного или несколько юридических документов лучше всего отвечают на запрос пользователя. Если вопрос касается человека, то подумай, есть ли у тебя документ "Информация о сотруднике" с подходящим именем.
179
+ 2) Сопоставь запрос пользователя и юридические документы. Выпиши номера документов, которые подходят для ответа на запрос "Документ [номер]". Если ни в одном документе нет нужной информации для ответа на вопрос пользователя, то твой ответ "Извините, я не нашла нужную информацию". Кроме перечисления нужных документов ничего нельзя писать в этом пункте
180
+ 3) НАПИШИ '%%'. Затем составь ответ на запрос. Старайся опираться в ответе на те документы, номера которых ты выписал ранее. При ответе тебе можно использовать смысловую нагрузку "Названий документов", но нельзя выписывать эти названия документов. Не дублируй одинаковый текст из разных документов. Если запрос может иметь несколько различных смыслов, а ответ в предоставленных документах только по одному из них, то укажи пользователю, что для получения ответа на другой смысл запроса требуется уточнение. Если в предыдущем шаге ты не нашёл подходящих документов, то напиши 'Информации в найденных документах нет, попробуйте перефразировать запрос', а затем, если вопрос твоего профиля (касается информации по документам компании), поп��обуй самостоятельно порассуждать.
181
+ 4) Выпиши все названия документов, которые ты ранее использовал в своём ответе в виде списка. Если в ответе ты не использовал ни одного документа или если ты не нашёл ответа на вопрос, то поставь '-'.
182
+ 5) Напиши 'Конец ответа'.
183
+ Конец плана.
184
+ Итоговый текст должен выглядеть так: "Какие документы нужны: [твои рассуждения что нужно найти]
185
+ В каких документах есть ответ:
186
+ [перечисление номеров документов]
187
+ %%Ответ на запрос:
188
+ [твои мысли, если цитат не хватает для ответа на вопрос]
189
+ Список документов:
190
+ [Названия документов]
191
+ Конец ответа."
192
+ ####
193
+ Далее будет первый структурный шаблон, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
194
+ ####
195
+ Запрос пользователя: Какие действия являются первоочередными в момент обнаружения происшествия?
196
+
197
+ Отрывки из юридических документов: Документ: [1]
198
+ Название документа: УЧЕТ И РАССЛЕДОВАНИЕ
199
+ ...Дополнительные разделы:
200
+ 5.1.1 Первоочередными действиями в момент обнаружения происшествия являются:
201
+ - обеспечение безопасности работников Компании и третьих лиц;
202
+ - оперативное информирование (в соответствии с Приложением А);
203
+ - принятие мер по сохранению места происшествия;
204
+ - сбор детальной информации о происшествии;
205
+ - принятие мер по минимизации негативного воздействия на окружающую среду при его наличии.
206
+ ...
207
+ - оперативное патрулирование (в соответствии с Приложением Б);
208
+ ...
209
+ \
210
+ Документ: [2]
211
+ Название документа: $S_СТАНДАРТ ОРГАНИЗАЦИИ
212
+ ...Дополнительные разделы:
213
+ 5. Порядок действий при происшествии...
214
+ ####
215
+ Вывод:
216
+ Какие документы нужны: По заданному вопросу нужны документы, связанные с происшествиями и порядком действий в момент их обнаружения.
217
+ В каких документах есть ответ: Документ [1]
218
+
219
+ %%Ответ на запрос:
220
+ 5.1.1 Первоочередными действиями в момент обнаружения происшествия являются:
221
+ - обеспечение безопасности работников Компании и третьих лиц;
222
+ - оперативное информирование (в соответствии с Приложением А);
223
+ - принятие мер по сохранению места происшествия;
224
+ - сбор детальной информации о происшествии;
225
+ - принятие мер по минимизации негативного воздействия на окружающую среду при его наличии.
226
+
227
+ Список документов:
228
+ * Документ: [1]
229
+ Название документа: УЧЕТ И РАССЛЕДОВАНИЕ
230
+
231
+ Конец ответа.
232
+ ####
233
+ Далее будет второй структурный шаблон, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
234
+ ####
235
+ Запрос пользователя: В состав каких групп входит Позлов М.М.?
236
+ Отрывки из юридических документов: Документ: [1]
237
+ Информация о сотруднике Позлов М.М.
238
+ [
239
+ Должность: Старший вице-президент - Операционный директор, руководитель Забайкальского дивизиона
240
+ Руководит следующими сотрудниками:
241
+ Селезнев С.С.
242
+ Манукян А.Г.
243
+ Кузнецов А.В.
244
+ Руководителем Попов А.Н. является Потанин В.О.
245
+ ]
246
+ Отвечает за Бизнес процессы:
247
+ Производственно-техническое развитие
248
+ Производство
249
+ Является Бизнес-куратором (РОКС НН):
250
+ ООО «Ширинское»ООО «Быстринская сервисная компания»ООО «Бугдаинский рудник»ООО «Востокгеология»ООО «Восточная ГРК»АО
251
+ Входит в состав групп:
252
+ Составы Комиссий по проведению специальной оценки условий тр уда в Главном офисе ПАО "ГМК "Норильский никель". Должность внутри группы: Председатель Комиссии 2
253
+ Состав Научно-технического совета
254
+ Состав Инвестиционного комитета. Должность внутри группы: Постоянные члены Комитета
255
+ \
256
+ Документ: [2]
257
+ Информация о сотруднике Кузнецов А.В.
258
+ [
259
+ Должность: Директор департамента гражданской обороны
260
+ ]
261
+ Входит в состав групп:
262
+ Персональный состав Рабочей группы по контролю за подготовкой к паводковому сезону 2024. Должность внутри группы: Член Рабочей группы
263
+ ####
264
+ Вывод:
265
+ Какие документы нужны: По заданному вопросу нужны документы, в которых есть информация о составах групп Позлова М.М.
266
+ В каких документах есть ответ:
267
+ Документ [1]
268
+
269
+ %%Ответ на запрос: Согласно найденной информации Позлов М.М. входит в следующий состав групп: составы Комиссий по проведению специальной оценки условий труда в Главном офисе ПАО "ГМК "Норильский никель", состав Научно-технического совета и состав Инвестиционного комитета.
270
+
271
+ Список документов:
272
+ Документ [1]
273
+ Информация о сотруднике Позлов М.М.
274
+
275
+ Конец ответа.
276
+ ####
277
+ Далее будет третий структурный шаблон, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
278
+ ####
279
+ Запрос пользователя: Что такое ДМД?
280
+
281
+ Отрывки из юридических документов: Документ: [1]
282
+ Название документа: ОРГАНИЗАЦИИ ААААААА
283
+ ...
284
+ Нет ничего хорошего. Всё съели мухи.
285
+ ...
286
+ \
287
+ Документ: [2]
288
+ Название документа: $S_ПОЛОЖЕНИЕ О.DOCX
289
+ ...ДМД лучше использовать при налоговой ставке в 12%.
290
+ ...ДМД очень важно...
291
+ \
292
+ Документ: [3]
293
+ Название документа: $S_ПОЛОЖЕНИЕ Е.DOCX
294
+ ...От грубых производственных деталей. Если ваш ДМД достаточно крупный, то разделите его. Не пытайтесь помыть станок, он чистый. Где слон? Не вижу я никаких заводов.
295
+ ...
296
+ \
297
+ Документ: [4]
298
+ Название документа: $S_ПОЛОЖЕНИЕ Р.DOCX
299
+ ...ДМД лучше использовать при налоговой ставке в 12%.
300
+ ...
301
+ ####
302
+ Вывод:
303
+ Какие документы нужны: Для ответа на вопрос нужны документы, где есть определение ДМД. Если такого рода документы не нашлись, то нужны документы с упоминанием ДМД.
304
+ В каких документах есть ответ:
305
+ Документах [2], [3], [4]
306
+
307
+ %%Ответ на запрос: В найденных документах нет определения или расшифровки понятия ДМД из вашего запроса. Согласно документам это нечто, что можно использовать при налоговой ставке и, если оно достаточно крупное, то его можно разделять. Также ДМД очень важно. Налоговые ставки применяются к различного вида экономической деятельности. Также подобную деятельность можно разделить на части - филиалы. Возможно ДМД связано именно с этим.
308
+
309
+ Список документов:
310
+ *Документ: [2]
311
+ Название документа: $S_ПОЛОЖЕНИЕ О.DOCX
312
+ *Документ: [3]
313
+ Название документа: $S_ПОЛОЖЕНИЕ Е.DOCX
314
+ *Документ: [4]
315
+ Название документа: $S_ПОЛОЖЕНИЕ Р.DOCX
316
+
317
+ Конец ответа.
318
+ ####
319
+ Далее будет настоящий запрос
320
+ ####
321
+ Запрос пользователя: {query}
322
+ Отрывки из юридических документов: {answer}
323
+ ####
324
+ Вывод: [/INST]"""
325
+
326
+
327
+ PROMPT_NAME = """ [INST] Ты мастер по правильным ответам. Твоя цель - дать правильный ответ на основе заданных тебе источников. Я задам тебе запрос о конкретном человеке или связи человека и дам список информации о людях. Основные правила:
328
+ - Тебе нужно максимально чётко ответить на поставленный запрос используя ТОЛЬКО информацию из списка.
329
+ - Если нужной информации в списке нет, то пиши в ответе "Извините, не смогла найти нужную информацию по источникам". Не нужно выдумывать информацию.
330
+ - Если тебя просят перечислить должности для одного человека, то перечисляй их с более важной к наименее.
331
+ - Не пиши в ответ "#####", это для разграничения.
332
+ - Не пиши должности человека в квадратных скобках [], это смысловое разграничение для тебя.
333
+ - Сформулируй ответ на официально-деловом РУССКОМ языке, избегай канцеляризмов, штампов, вводных конструкций.
334
+ - Если инициалы из запроса и предоставленных документов не совпадают, то это разные люди. Например "Иванов А.А." и "Иванов А.И." - это разные люди.
335
+ Конец основных правил.
336
+ Ты действуешь по плану. Начало плана:
337
+ 1) Прочитай вопрос и напиши для себя что именно тебе нужно сделать для вывода правильного ответа на вопрос.
338
+ 2) Выведи ответ на вопрос, следуя основным правилам и используя предоставленные источники.
339
+ Конец плана.
340
+ Твой ответ должен следовать шаблону "'твои рассуждения из пункта 1'
341
+ 2. Ответ:'ответ на вопрос пользователя'"
342
+ Отвечай всегда только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ! Не пиши в ответ "#####", это для разграничения.
343
+ #####
344
+ Далее будет первый структурный шаблон с правильной логикой, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
345
+ #####
346
+ Запрос о конкретном человеке: В состав каких групп входит Пидемский А.Н.?
347
+ Список информации о людях: Документ: [1]
348
+ Информация о сотруднике Манихин А.Н.
349
+ [
350
+ Должность: Почтальон
351
+ ]
352
+ Входит в состав группы:
353
+ Состав Архитектурного подкомитета ИТ-комитета. Должность внутри группы: Заместитель Председателя Архитектурного подкомитета ИТ-комитета
354
+ \
355
+
356
+ Документ: [2]
357
+ Информация о сотруднике Пидемский А.Н.
358
+ [
359
+ Должность: АО "Кольская ГМК" (по согласованию)
360
+ ]
361
+ Входит в состав группы:
362
+ Персональный состав Рабочей группы по разработке мероприятий по реализации ключевых направлений Программы повышения эффективности закупок в ПАО "ГМК "Норильский никель". Должность внутри группы: Член Рабочей группы
363
+ \
364
+
365
+ Документ: [3]
366
+ Информация о сотруднике Иванова А.Н.
367
+ [
368
+ Должность: Руководитель по направлению правового сопровождения закупочной деятельности Правового ��епартамента
369
+ ]
370
+ Входит в состав группы:
371
+ Персональный состав Рабочей группы по разработке мероприятий по реализации ключевых направлений Программы повышения эффективности закупок в ПАО "ГМК "Норильский никель". Должность внутри группы: Член Рабочей группы
372
+ \
373
+ #####
374
+ Вывод:
375
+ Мне нужно найти в состав каких групп входит Пидемский А.Н..
376
+ Ответ: Пидемский А.Н. входит в состав группы:
377
+ Персональный состав Рабочей группы по разработке мероприятий по реализации ключевых направлений Программы повышения эффективности закупок в ПАО "ГМК "Норильский никель".
378
+ #####
379
+ Далее будет второй структурный шаблон с правильной логикой, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
380
+ #####
381
+ Запрос о конкретном человеке: Какая должность у Петрова Н.В.?
382
+ Список информации о людях:Документ: [1]
383
+ Информация о сотруднике Пидемский А.Н.
384
+ [
385
+ Должность: АО "Кольская ГМК" (по согласованию)
386
+ ]
387
+ Входит в состав группы:
388
+ Персональный состав Рабочей группы по разработке мероприятий по реализации ключевых направлений Программы повышения эффективности закупок в ПАО "ГМК "Норильский никель". Должность внутри группы: Член Рабочей группы
389
+ \
390
+
391
+ Документ: [2]
392
+ Информация о сотруднике Иванова А.Н.
393
+ [
394
+ Должность: Руководитель по направлению правового сопровождения закупочной деятельности Правового департамента
395
+ ]
396
+ Входит в состав группы:
397
+ Персональный состав Рабочей группы по разработке мероприятий по реализации ключевых направлений Программы повышения эффективности закупок в ПАО "ГМК "Норильский никель". Должность внутри группы: Член Рабочей группы
398
+ \
399
+ #####
400
+ Вывод:
401
+ Мне нужно найти должность Петрова Н.В..
402
+ Ответ:
403
+ Извините, не смогла найти нужную информацию по источникам.
404
+ #####
405
+ Далее будет третий структурный шаблон с правильной логикой, по которому ты отвечаешь. НЕ ИСПОЛЬЗУЙ данные из этого шаблона, он показывает только пример твоей работы
406
+ #####
407
+ Запрос о конкретном человеке: Кузнецов А.В.
408
+ Список информации о людях:Документ: [1]
409
+ Информация о сотруднике Попов А.Н.
410
+ [
411
+ Должность: Старший вице-президент - Операционный директор, руководитель Забайкальского дивизиона
412
+ Руководит следующими сотрудниками:
413
+ Селезнев С.С.
414
+ Манукян А.Г.
415
+ Кузнецов А.В.
416
+ Руководителем Попов А.Н. является Потанин В.О.
417
+ ]
418
+ Отвечает за Бизнес процессы:
419
+ Производственно-техническое развитие
420
+ Производство
421
+ Является Бизнес-куратором (РОКС НН):
422
+ ООО «Ширинское»ООО «Быстринская сервисная компания»ООО «Бугдаинский рудник»ООО «Востокгеология»ООО «Восточная ГРК»АО
423
+ Входит в состав групп:
424
+ Составы Комиссий по проведению специальной оценки условий тр уда в Главном офисе ПАО "ГМК "Норильский никель". Должность внутри группы: Председатель Комиссии 2
425
+ Состав Научно-технического совета
426
+ Состав Инвестиционного комитета. Должность внутри группы: Постоянные члены Комитета
427
+ \
428
+ Документ: [2]
429
+ Информация о сотруднике Кузнецов А.В.
430
+ [
431
+ Должность: Директор департамента гражданской обороны, предупреждения чрезвычайных ситуаций и пожарной безопасности ПАО "ГМК "Норильский никель"
432
+ ]
433
+ Входит в состав групп:
434
+ Персональный состав Рабочей группы по контролю за подготовкой гидротехнических сооружений объектов промышленности Компании и РОКС НН к паводковому сезону 2024. Должность внутри группы: Член Рабочей группы
435
+ #####
436
+ Вывод:
437
+ В запросе имя человека. Пользователь хочет получить всю возможную информацию о Кузнецове А.В.
438
+ Ответ:
439
+ Информация о сотруднике Кузнецов А.В.
440
+ Должность: Директор департамента гражданской обороны, предупреждения чрезвычайных ситуаций и пожарной безопасности ПАО "ГМК "Норильский никель"
441
+ Входит в состав групп:
442
+ Персональный состав Рабочей группы по контролю за подготовкой гидротехнических сооружений объектов промышленности Компании и РОКС НН к паводковому сезону 2024. Должность внутри группы: Член Рабочей группы
443
+ Руководителем Кузнецова А.В. является Попов А.Н.
444
+ #####
445
+ Далее будет настоящий запрос
446
+ #####
447
+ Запрос о конкретном человеке: {query}
448
+ Список информации о людях: {answer}
449
+ #####
450
+ Вывод: [/INST]"""
451
+
452
+
453
+ ERROR = '500 Internal Server Error'
454
+
455
+ ELASTIC_INDEX_PEOPLE = 'people_search'
456
+ DEVICE = 'cuda'
457
+ DO_NORMALIZATION = True
458
+ MODEL_PATH = './models/multilingual_e5_base/snapshots/file_model'
459
+ COLUMN_EMBEDDING = 'Embedding'
460
+ COLUMN_DOC_NAME = 'DocName'
461
+ COLUMN_LABELS_STR = 'labels'
462
+ COLUMN_TEXT = 'Text'
463
+
464
+ # Константы для карт проводок
465
+ COLUMN_EMBEDDING_FULL = 'EmbeddingFull'
466
+ COLUMN_TABLE_NAME = 'TableName'
467
+ COLUMN_NAMES = 'Columns'
468
+ COLUMN_TYPE_DOC_MAP = 'TypeDocs'
469
+
470
+ # Константы для PDF
471
+ COLUMN_SLIDE_NUMBER = 'SlideNumber'
472
+
473
+ # Константы для подготовки датасета
474
+ UNKNOWN = "unknown"
475
+ PROCESSING_FORMATS = ['XML', 'DOCX']
common/db.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import Depends
3
+ import logging
4
+
5
+ from typing import Annotated
6
+ from sqlalchemy import create_engine
7
+ from sqlalchemy.orm import sessionmaker, scoped_session, Session
8
+
9
+ from common.configuration import Configuration
10
+ from components.dbo.models.base import Base
11
+ import components.dbo.models.feedback
12
+ import components.dbo.models.acronym
13
+ import components.dbo.models.dataset
14
+ import components.dbo.models.dataset_document
15
+ 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
+ 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)
28
+ SessionLocal = scoped_session(session_factory)
29
+
30
+ def get_db_session_factory():
31
+
32
+ db = session_factory()
33
+ try:
34
+ yield db
35
+ finally:
36
+ db.close()
37
+
38
+ logger.info("Creating tables...")
39
+ Base.metadata.create_all(bind=engine)
common/dependencies.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
22
+ def get_config() -> Configuration:
23
+ return Configuration(os.environ.get('CONFIG_PATH', 'config_dev.yaml'))
24
+
25
+
26
+ def get_db() -> sessionmaker:
27
+ return session_factory
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)
common/exceptions.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+
3
+
4
+ class FeedbackNotFoundException(HTTPException):
5
+ def __init__(self, feedback_id: int):
6
+ super().__init__(status_code=404, detail=f"Отзыв id={feedback_id} не найден")
7
+
8
+ class LLMResponseException(HTTPException):
9
+ def __init__(self, detail: str = "Не удалось получить ответ LLM"):
10
+ super().__init__(status_code=400, detail=detail)
11
+
12
+ class LogNotFoundException(HTTPException):
13
+ def __init__(self, log_id: int):
14
+ super().__init__(status_code=404, detail=f"Лог id={log_id} не найден")
15
+
16
+ class InvalidUserScoreException(HTTPException):
17
+ def __init__(self, userScore: int):
18
+ super().__init__(status_code=400, detail=f"Невалидная оценка {userScore} ответа LLM")
19
+
20
+ class InvalidEstimateException(HTTPException):
21
+ def __init__(self, estimate_value: int):
22
+ super().__init__(status_code=400, detail=f"Невалидная оценка {estimate_value} времени")
components/datasets/dispatcher.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import re
3
+ from logging import Logger
4
+ from pathlib import Path
5
+ from typing import Dict, List, Tuple
6
+
7
+ import pandas as pd
8
+ from elasticsearch.exceptions import ConnectionError
9
+ from natasha import Doc, MorphVocab, NewsEmbedding, NewsMorphTagger, Segmenter
10
+
11
+ from common.common import (
12
+ get_elastic_abbreviation_query,
13
+ get_elastic_group_query,
14
+ get_elastic_people_query,
15
+ get_elastic_query,
16
+ get_elastic_rocks_nn_query,
17
+ get_elastic_segmentation_query,
18
+ )
19
+ from common.configuration import Configuration, Query, SummaryChunks
20
+ from common.constants import PROMPT, PROMPT_CLASSIFICATION
21
+ from components.elastic import create_index_elastic_chunks
22
+ from components.elastic.elasticsearch_client import ElasticsearchClient
23
+ from components.embedding_extraction import EmbeddingExtractor
24
+ from components.nmd.aggregate_answers import aggregate_answers
25
+ from components.nmd.faiss_vector_search import FaissVectorSearch
26
+ from components.nmd.llm_chunk_search import LLMChunkSearch
27
+ from components.nmd.metadata_manager import MetadataManager
28
+ from components.nmd.query_classification import QueryClassification
29
+ from components.nmd.rancker import DocumentRanking
30
+
31
+ from components.services.dataset import DatasetService
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class Dispatcher:
37
+ def __init__(
38
+ self,
39
+ embedding_model: EmbeddingExtractor,
40
+ config: Configuration,
41
+ logger: Logger,
42
+ dataset_service: DatasetService
43
+ ):
44
+ self.dataset_service = dataset_service
45
+ self.config = config
46
+ self.embedder = embedding_model
47
+ self.dataset_id = None
48
+
49
+ self.try_load_default_dataset()
50
+
51
+ self.llm_search = LLMChunkSearch(config.llm_config, PROMPT, logger)
52
+ if self.config.db_config.elastic.use_elastic:
53
+ self.elastic_search = ElasticsearchClient(
54
+ host=f'{config.db_config.elastic.es_host}',
55
+ port=config.db_config.elastic.es_port,
56
+ )
57
+
58
+ self.query_classification = QueryClassification(
59
+ config.llm_config, PROMPT_CLASSIFICATION, logger
60
+ )
61
+ self.segmenter = Segmenter()
62
+ self.morph_tagger = NewsMorphTagger(NewsEmbedding())
63
+ self.morph_vocab = MorphVocab()
64
+
65
+ def try_load_default_dataset(self):
66
+ default_dataset = self.dataset_service.get_default_dataset()
67
+ if default_dataset is not None and default_dataset.id is not None and default_dataset.id != self.dataset_id:
68
+ logger.info(f'Reloading dataset {default_dataset.id}')
69
+ self.reset_dataset(default_dataset.id)
70
+ else:
71
+ self.faiss_search = None
72
+ self.meta_database = None
73
+
74
+ def reset_dataset(self, dataset_id: int):
75
+ logger.info(f'Reset dataset to dataset_id: {dataset_id}')
76
+ data_path = Path(self.config.db_config.faiss.path_to_metadata)
77
+ df = pd.read_pickle(data_path / str(dataset_id) / 'dataset.pkl')
78
+ logger.info(f'Dataset loaded from {data_path / str(dataset_id) / "dataset.pkl"}')
79
+ logger.info(f'Dataset shape: {df.shape}')
80
+ self.faiss_search = FaissVectorSearch(self.embedder, df, self.config.db_config)
81
+ logger.info(f'Faiss search initialized')
82
+ self.meta_database = MetadataManager(df, logger)
83
+ logger.info(f'Meta database initialized')
84
+
85
+ if self.config.db_config.elastic.use_elastic:
86
+ create_index_elastic_chunks(df, logger)
87
+ logger.info(f'Elastic index created')
88
+ self.document_ranking = DocumentRanking(df, self.config)
89
+ logger.info(f'Document ranking initialized')
90
+
91
+ def __vector_search(self, query: str) -> Dict[int, Dict]:
92
+ """
93
+ Метод для поиска ближайших векторов по векторной базе Faiss.
94
+ Args:
95
+ query: Запрос пользователя.
96
+
97
+ Returns:
98
+ возвращает словарь chunks.
99
+ """
100
+ query_embeds, scores, indexes = self.faiss_search.search_vectors(query)
101
+ if self.config.db_config.ranker.use_ranging:
102
+ indexes = self.document_ranking.doc_ranking(query_embeds, scores, indexes)
103
+ return self.meta_database.search(indexes)
104
+
105
+ def __elastic_search(
106
+ self, query: str, index_name: str, search_function, size: int
107
+ ) -> Dict:
108
+ """
109
+ Метод для полнотекстового поиска.
110
+ Args:
111
+ query: Запрос пользователя.
112
+ index_name: Наименование индекса.
113
+ search_function: Функция запроса, зависит от индекса по которому нужно искать.
114
+ size: Количество ближайших соседей, или размер выборки.
115
+
116
+ Returns:
117
+ Возвращает словарь c ответами.
118
+ """
119
+ self.elastic_search.set_index(index_name)
120
+ return self.elastic_search.search(query=search_function(query), size=size)
121
+
122
+ @staticmethod
123
+ def _get_indexes_full_text_elastic_search(elastic_answer: Dict) -> List:
124
+ """
125
+ Метод позволяет получить индексы чанков, которые нашел elastic.
126
+ Args:
127
+ elastic_answer: Результаты полнотекстового поиска по чанкам.
128
+
129
+ Returns:
130
+ Возвращает список индексов.
131
+ """
132
+ answer = []
133
+ for answer_dict in elastic_answer:
134
+ answer.append(answer_dict['_source']['index'])
135
+ return answer
136
+
137
+ def _lemmatization_text(self, text: str):
138
+ doc = Doc(text)
139
+ doc.segment(self.segmenter)
140
+ doc.tag_morph(self.morph_tagger)
141
+
142
+ for token in doc.tokens:
143
+ token.lemmatize(self.morph_vocab)
144
+
145
+ return ' '.join([token.lemma for token in doc.tokens])
146
+
147
+ def _get_abbreviations(self, query: Query):
148
+ query_abbreviation = query.query_abbreviation
149
+ abbreviations_replaced = query.abbreviations_replaced
150
+ try:
151
+ if self.config.db_config.elastic.use_elastic:
152
+ if (
153
+ self.config.db_config.search.abbreviation_search.use_abbreviation_search
154
+ ):
155
+ abbreviation_answer = self.__elastic_search(
156
+ query=query.query,
157
+ index_name=self.config.db_config.search.abbreviation_search.index_name,
158
+ search_function=get_elastic_abbreviation_query,
159
+ size=self.config.db_config.search.abbreviation_search.k_neighbors,
160
+ )
161
+ if len(abbreviation_answer) > 0:
162
+ query_lemmatization = self._lemmatization_text(query.query)
163
+ for abbreviation in abbreviation_answer:
164
+ abbreviation_lemmatization = self._lemmatization_text(
165
+ abbreviation['_source']['text'].lower()
166
+ )
167
+ if abbreviation_lemmatization in query_lemmatization:
168
+ query_abbreviation_lemmatization = (
169
+ self._lemmatization_text(query_abbreviation)
170
+ )
171
+ index = re.search(
172
+ abbreviation_lemmatization,
173
+ query_abbreviation_lemmatization,
174
+ ).span()[1]
175
+ space_index = query_abbreviation.find(' ', index)
176
+ if space_index != -1:
177
+ query_abbreviation = '{} ({}) {}'.format(
178
+ query_abbreviation[:space_index],
179
+ abbreviation["_source"]["abbreviation"],
180
+ query_abbreviation[space_index:],
181
+ )
182
+ else:
183
+ query_abbreviation = '{} ({})'.format(
184
+ query_abbreviation,
185
+ abbreviation["_source"]["abbreviation"],
186
+ )
187
+ except ConnectionError:
188
+ logger.info("Connection Error Elasticsearch")
189
+
190
+ return Query(
191
+ query=query.query,
192
+ query_abbreviation=query_abbreviation,
193
+ abbreviations_replaced=abbreviations_replaced,
194
+ )
195
+
196
+ def search_answer(self, query: Query) -> SummaryChunks:
197
+ """
198
+ Метод для поиска чанков отвечающих на вопрос пользователя в разных типах поиска.
199
+ Args:
200
+ query: Запрос пользователя.
201
+
202
+ Returns:
203
+ Возвращает чанки найденные на запрос пользователя.
204
+ """
205
+ self.try_load_default_dataset()
206
+ query = self._get_abbreviations(query)
207
+
208
+ logger.info(f'Start search for {query.query_abbreviation}')
209
+ logger.info(f'Use elastic search: {self.config.db_config.elastic.use_elastic}')
210
+
211
+ answer = {}
212
+ if self.config.db_config.search.vector_search.use_vector_search:
213
+ logger.info('Start vector search.')
214
+ answer['vector_answer'] = self.__vector_search(query.query_abbreviation)
215
+ logger.info(f'Vector search found {len(answer["vector_answer"])} chunks')
216
+
217
+ try:
218
+ if self.config.db_config.elastic.use_elastic:
219
+ if self.config.db_config.search.people_elastic_search.use_people_search:
220
+ logger.info('Start people search.')
221
+ people_answer = self.__elastic_search(
222
+ query.query,
223
+ index_name=self.config.db_config.search.people_elastic_search.index_name,
224
+ search_function=get_elastic_people_query,
225
+ size=self.config.db_config.search.people_elastic_search.k_neighbors,
226
+ )
227
+ logger.info(f'People search found {len(people_answer)} chunks')
228
+ answer['people_answer'] = people_answer
229
+
230
+ if self.config.db_config.search.chunks_elastic_search.use_chunks_search:
231
+ logger.info('Start full text chunks search.')
232
+ chunks_answer = self.__elastic_search(
233
+ query.query,
234
+ index_name=self.config.db_config.search.chunks_elastic_search.index_name,
235
+ search_function=get_elastic_query,
236
+ size=self.config.db_config.search.chunks_elastic_search.k_neighbors,
237
+ )
238
+ indexes = self._get_indexes_full_text_elastic_search(chunks_answer)
239
+ chunks_answer = self.meta_database.search(indexes)
240
+ logger.info(
241
+ f'Full text chunks search found {len(chunks_answer)} chunks'
242
+ )
243
+ answer['chunks_answer'] = chunks_answer
244
+
245
+ if self.config.db_config.search.groups_elastic_search.use_groups_search:
246
+ logger.info('Start groups search.')
247
+ groups_answer = self.__elastic_search(
248
+ query.query,
249
+ index_name=self.config.db_config.search.groups_elastic_search.index_name,
250
+ search_function=get_elastic_group_query,
251
+ size=self.config.db_config.search.groups_elastic_search.k_neighbors,
252
+ )
253
+ if len(groups_answer) != 0:
254
+ logger.info(f'Groups search found {len(groups_answer)} chunks')
255
+ answer['groups_answer'] = groups_answer
256
+
257
+ if (
258
+ self.config.db_config.search.rocks_nn_elastic_search.use_rocks_nn_search
259
+ ):
260
+ logger.info('Start Rocks NN search.')
261
+ rocks_nn_answer = self.__elastic_search(
262
+ query.query,
263
+ index_name=self.config.db_config.search.rocks_nn_elastic_search.index_name,
264
+ search_function=get_elastic_rocks_nn_query,
265
+ size=self.config.db_config.search.rocks_nn_elastic_search.k_neighbors,
266
+ )
267
+ if len(rocks_nn_answer) != 0:
268
+ logger.info(
269
+ f'Rocks NN search found {len(rocks_nn_answer)} chunks'
270
+ )
271
+ answer['rocks_nn_answer'] = rocks_nn_answer
272
+
273
+ if (
274
+ self.config.db_config.search.segmentation_elastic_search.use_segmentation_search
275
+ ):
276
+ logger.info('Start Segmentation search.')
277
+ segmentation_answer = self.__elastic_search(
278
+ query.query,
279
+ index_name=self.config.db_config.search.segmentation_elastic_search.index_name,
280
+ search_function=get_elastic_segmentation_query,
281
+ size=self.config.db_config.search.segmentation_elastic_search.k_neighbors,
282
+ )
283
+ if len(segmentation_answer) != 0:
284
+ logger.info(
285
+ f'Segmentation search found {len(segmentation_answer)} chunks'
286
+ )
287
+ answer['segmentation_answer'] = segmentation_answer
288
+
289
+ except ConnectionError:
290
+ logger.info("Connection Error Elasticsearch")
291
+
292
+ final_answer = aggregate_answers(**answer)
293
+ logger.info(f'Final answer found {len(final_answer)} chunks')
294
+ return SummaryChunks(**final_answer)
295
+
296
+ def llm_classification(self, query: str) -> str:
297
+ type_query = self.query_classification.classification(query)
298
+ return type_query
299
+
300
+ def llm_answer(
301
+ self, query: str, answer_chunks: SummaryChunks
302
+ ) -> Tuple[str, str, str, int]:
303
+ """
304
+ Метод для поиска правильного ответа с помощью LLM.
305
+ Args:
306
+ query: Запрос.
307
+ answer_chunks: Ответы векторного поиска и elastic.
308
+
309
+ Returns:
310
+ Возвращает исходные chunks из поисков, и chunk который выбрала модель.
311
+ """
312
+ prompt = PROMPT
313
+ return self.llm_search.llm_chunk_search(query, answer_chunks, prompt)
components/dbo/models/acronym.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import (
2
+ ForeignKey,
3
+ Integer,
4
+ String,
5
+ )
6
+ from sqlalchemy.orm import mapped_column, relationship
7
+ from components.dbo.models.base import Base
8
+
9
+
10
+
11
+ class Acronym(Base):
12
+ __tablename__ = "acronym"
13
+
14
+ short_form = mapped_column(String)
15
+ full_form = mapped_column(String)
16
+ type = mapped_column(String)
17
+ document_id = mapped_column(Integer, ForeignKey('document.id'), nullable=True)
18
+
19
+ document = relationship("Document", back_populates="acronyms")
components/dbo/models/base.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import (
4
+ DateTime,
5
+ Integer
6
+ )
7
+ from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
8
+
9
+ class Base(DeclarativeBase):
10
+ """Базовая модель с id, датой создания и датой удаления."""
11
+
12
+
13
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
14
+ date_created: Mapped[datetime] = mapped_column(
15
+ DateTime, default=datetime.now(timezone.utc), nullable=False
16
+ )
17
+ date_removed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
18
+
19
+ def to_dict(self):
20
+ return {c.name: getattr(self, c.name) for c in self.__table__.columns}
components/dbo/models/dataset.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import (
2
+ Boolean,
3
+ ForeignKey,
4
+ Integer,
5
+ String,
6
+ )
7
+ from sqlalchemy.orm import Mapped, relationship, mapped_column
8
+
9
+ from components.dbo.models.base import Base
10
+
11
+ class Dataset(Base):
12
+ """
13
+ Сущность, которая хранит информацию о датасете.
14
+ """
15
+
16
+ __tablename__ = "dataset"
17
+
18
+ name: Mapped[str] = mapped_column(String, unique=True)
19
+ is_draft: Mapped[bool] = mapped_column(Boolean, default=True)
20
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
21
+ previous_dataset_id: Mapped[int] = mapped_column(Integer, ForeignKey("dataset.id"), nullable=True)
22
+
23
+ documents: Mapped[list["DatasetDocument"]] = relationship(
24
+ "DatasetDocument", back_populates="dataset",
25
+ cascade="all, delete-orphan"
26
+ )
components/dbo/models/dataset_document.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import (
2
+ ForeignKey,
3
+ Integer,
4
+ )
5
+ from sqlalchemy.orm import Mapped, relationship, mapped_column
6
+ from components.dbo.models.base import Base
7
+
8
+
9
+ class DatasetDocument(Base):
10
+ """
11
+ Отношение многие ко многим между документами и датасетами.
12
+ """
13
+
14
+ __tablename__ = "dataset_document"
15
+
16
+ dataset_id: Mapped[int] = mapped_column(
17
+ Integer, ForeignKey('dataset.id', ondelete='CASCADE'), index=True
18
+ )
19
+ document_id: Mapped[int] = mapped_column(
20
+ Integer, ForeignKey('document.id', ondelete='CASCADE'), index=True
21
+ )
22
+
23
+ dataset: Mapped["Dataset"] = relationship("Dataset", back_populates='documents')
24
+ document: Mapped["Document"] = relationship("Document", back_populates='datasets')
components/dbo/models/document.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ from sqlalchemy import (
4
+ String,
5
+ )
6
+ from sqlalchemy.orm import Mapped, relationship, mapped_column
7
+ from components.dbo.models.base import Base
8
+
9
+ class Document(Base):
10
+ """
11
+ Сущность, которая хранит основную информацию о документе.
12
+ """
13
+
14
+ __tablename__ = "document"
15
+
16
+ filename: Mapped[str] = mapped_column(String)
17
+ source_format: Mapped[str] = mapped_column(String)
18
+ title: Mapped[str] = mapped_column(String)
19
+ status: Mapped[str] = mapped_column(String)
20
+ owner: Mapped[str] = mapped_column(String)
21
+
22
+ datasets: Mapped[list["DatasetDocument"]] = relationship(
23
+ 'DatasetDocument', back_populates='document'
24
+ )
25
+ acronyms = relationship("Acronym", back_populates="document")
components/dbo/models/feedback.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import (
2
+ Boolean,
3
+ CheckConstraint,
4
+ Column,
5
+ DateTime,
6
+ ForeignKey,
7
+ Integer,
8
+ String,
9
+ )
10
+ from sqlalchemy.orm import mapped_column, relationship
11
+ from components.dbo.models.base import Base
12
+
13
+
14
+
15
+ class Feedback(Base):
16
+ __tablename__ = 'feedback'
17
+
18
+ userComment = mapped_column(String)
19
+ userScore = mapped_column(
20
+ Integer, CheckConstraint("userScore > 0 AND userScore < 6"), nullable=False
21
+ )
22
+ manualEstimate = mapped_column(Integer)
23
+ llmEstimate = mapped_column(Integer)
24
+
25
+ log_id = mapped_column(Integer, ForeignKey('log.id'), index=True)
26
+
27
+ log = relationship("Log", back_populates="feedback")
components/dbo/models/llm_config.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import (
2
+ Boolean,
3
+ String,
4
+ Integer,
5
+ Float
6
+ )
7
+ from sqlalchemy.orm import Mapped, mapped_column
8
+
9
+ from components.dbo.models.base import Base
10
+
11
+
12
+ class LLMConfig(Base):
13
+ """
14
+ Сущность, которая хранит параметры вызова ЛЛМ.
15
+ """
16
+
17
+ __tablename__ = "llm_config"
18
+
19
+ is_default: Mapped[bool] = mapped_column(Boolean, is_default=False)
20
+ model: Mapped[String] = mapped_column(String)
21
+ temperature: Mapped[float] = mapped_column(Float)
22
+ top_p: Mapped[float] = mapped_column(Float)
23
+ min_p: Mapped[float] = mapped_column(Float)
24
+ frequency_penalty: Mapped[float] = mapped_column(Float)
25
+ presence_penalty: Mapped[float] = mapped_column(Float)
26
+ n_predict: Mapped[int] = mapped_column(Integer)
27
+ seed: Mapped[int] = mapped_column(Integer)
28
+
29
+ #TODO: вынести в базовый класс
30
+ def to_dict(self):
31
+ return {c.name: getattr(self, c.name) for c in self.__table__.columns}
components/dbo/models/llm_prompt.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import (
2
+ Boolean,
3
+ String
4
+ )
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+ from components.dbo.models.base import Base
8
+
9
+
10
+ class LlmPrompt(Base):
11
+ """
12
+ Настройки промптов для ллм.
13
+ """
14
+
15
+ __tablename__ = "llm_prompt"
16
+
17
+ is_default: Mapped[bool] = mapped_column(Boolean, is_default=False)
18
+ name: Mapped[String] = mapped_column(String)
19
+ text: Mapped[String] = mapped_column(String)
20
+ type: Mapped[String] = mapped_column(String)
21
+
components/dbo/models/log.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import (
2
+ Integer,
3
+ String,
4
+ )
5
+ from sqlalchemy.orm import relationship, mapped_column
6
+ from components.dbo.models.base import Base
7
+
8
+
9
+ class Log(Base):
10
+ __tablename__ = 'log'
11
+
12
+ llmPrompt = mapped_column(String)
13
+ llmResponse = mapped_column(String)
14
+ llm_classifier = mapped_column(String)
15
+ userRequest = mapped_column(String)
16
+ query_type = mapped_column(String)
17
+ userName = mapped_column(String)
18
+
19
+ feedback = relationship("Feedback", back_populates="log")
components/elastic/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from .create_index_elastic import create_index_elastic_people
2
+ from .create_index_elastic_chunks import create_index_elastic_chunks
3
+
4
+ __all__ = [
5
+ 'create_index_elastic_chunks',
6
+ 'create_index_elastic_people',
7
+ ]
components/elastic/create_index_elastic.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from elasticsearch import Elasticsearch
8
+ from tqdm import tqdm
9
+
10
+ ROOT_DIR = Path(__file__).resolve().parent.parent.parent
11
+ if ROOT_DIR not in sys.path:
12
+ sys.path.append(str(ROOT_DIR))
13
+
14
+
15
+ def create_index_elastic_people(
16
+ path: str,
17
+ logger: logging.Logger | None = None,
18
+ ):
19
+ if logger is None:
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Подключение к Elasticsearch
23
+ es = Elasticsearch(hosts='localhost:9200')
24
+ INDEX_NAME = 'people_search'
25
+
26
+ # Удаление старого индекса, если он существует
27
+ if es.indices.exists(index=INDEX_NAME):
28
+ es.indices.delete(index=INDEX_NAME)
29
+
30
+ mapping = {
31
+ "settings": {
32
+ "analysis": {
33
+ "char_filter": {
34
+ "quote_removal": {
35
+ "type": "pattern_replace",
36
+ "pattern": "[\"«»]",
37
+ "replacement": "",
38
+ }
39
+ },
40
+ "filter": {
41
+ # "russian_stemmer": {
42
+ # "type": "stemmer",
43
+ # "name": "russian"
44
+ # },
45
+ "custom_stopwords": {
46
+ "type": "stop",
47
+ "stopwords": [
48
+ "кто",
49
+ "является",
50
+ "куратором",
51
+ "руководит",
52
+ "отвечает",
53
+ "бизнес",
54
+ "за что",
55
+ "ООО",
56
+ "ОАО",
57
+ "НН",
58
+ "персональный",
59
+ "состав",
60
+ "персональный",
61
+ "состав",
62
+ "Комитета",
63
+ "ПАО",
64
+ "ГМК",
65
+ "Норильский никель",
66
+ "Рабочей группы",
67
+ "что",
68
+ "как",
69
+ "почему",
70
+ "зачем",
71
+ "где",
72
+ "когда",
73
+ ],
74
+ }
75
+ },
76
+ "analyzer": {
77
+ "custom_analyzer": {
78
+ "type": "custom",
79
+ "char_filter": ["quote_removal"],
80
+ "tokenizer": "standard",
81
+ "filter": [
82
+ "lowercase",
83
+ "custom_stopwords",
84
+ # "russian_stemmer"
85
+ ],
86
+ }
87
+ },
88
+ }
89
+ },
90
+ "mappings": {
91
+ "properties": {
92
+ "business_processes": {
93
+ "type": "nested",
94
+ "properties": {
95
+ "production_activities_section": {
96
+ "type": "text",
97
+ "analyzer": "custom_analyzer",
98
+ "search_analyzer": "custom_analyzer",
99
+ },
100
+ "processes_name": {
101
+ "type": "text",
102
+ "analyzer": "custom_analyzer",
103
+ "search_analyzer": "custom_analyzer",
104
+ },
105
+ "level_process": {
106
+ "type": "text",
107
+ "analyzer": "custom_analyzer",
108
+ "search_analyzer": "custom_analyzer",
109
+ },
110
+ },
111
+ },
112
+ "organizatinal_structure": {
113
+ "type": "nested",
114
+ "properties": {
115
+ "position": {
116
+ "type": "text",
117
+ "analyzer": "custom_analyzer",
118
+ "search_analyzer": "custom_analyzer",
119
+ },
120
+ "leads": {
121
+ "type": "nested",
122
+ "properties": {
123
+ "0": {
124
+ "type": "text",
125
+ "analyzer": "custom_analyzer",
126
+ "search_analyzer": "custom_analyzer",
127
+ },
128
+ "1": {
129
+ "type": "text",
130
+ "analyzer": "custom_analyzer",
131
+ "search_analyzer": "custom_analyzer",
132
+ },
133
+ },
134
+ },
135
+ "subordinate": {
136
+ "type": "object",
137
+ "properties": {
138
+ "person_name": {
139
+ "type": "text",
140
+ "analyzer": "custom_analyzer",
141
+ "search_analyzer": "custom_analyzer",
142
+ },
143
+ "position": {
144
+ "type": "text",
145
+ "analyzer": "custom_analyzer",
146
+ "search_analyzer": "custom_analyzer",
147
+ },
148
+ },
149
+ },
150
+ },
151
+ },
152
+ "business_curator": {
153
+ "type": "nested",
154
+ "properties": {
155
+ "division": {
156
+ "type": "text",
157
+ "analyzer": "custom_analyzer",
158
+ "search_analyzer": "custom_analyzer",
159
+ },
160
+ "company_name": {
161
+ "type": "text",
162
+ "analyzer": "custom_analyzer",
163
+ "search_analyzer": "custom_analyzer",
164
+ },
165
+ },
166
+ },
167
+ "groups": {
168
+ "type": "nested",
169
+ "properties": {
170
+ "group_name": {
171
+ "type": "text",
172
+ "analyzer": "custom_analyzer",
173
+ "search_analyzer": "custom_analyzer",
174
+ },
175
+ "position_in_group": {
176
+ "type": "text",
177
+ "analyzer": "custom_analyzer",
178
+ "search_analyzer": "custom_analyzer",
179
+ },
180
+ "block": {"type": "keyword", "null_value": "unknown"},
181
+ },
182
+ },
183
+ "person_name": {
184
+ "type": "text",
185
+ "analyzer": "custom_analyzer",
186
+ "search_analyzer": "custom_analyzer",
187
+ },
188
+ }
189
+ },
190
+ }
191
+ # Создание индекса с указанным маппингом
192
+ es.indices.create(index=INDEX_NAME, body=mapping)
193
+
194
+ group_names = []
195
+ for ind, path in tqdm(enumerate(Path(path).iterdir())):
196
+ # Открываем файл и читаем его содержимое
197
+ try:
198
+ with open(path, 'r', encoding='utf-8') as file:
199
+ data = json.load(file)
200
+
201
+ # Индексирование документа в Elasticsearch
202
+ es.index(index=INDEX_NAME, id=ind + 1, body=data)
203
+ time.sleep(0.5)
204
+ except:
205
+ print(f"Ошибка при чтении или добавлении файла {path.name} в индекс")
206
+
207
+ if es.indices.exists(index=INDEX_NAME):
208
+ print(f"Index '{INDEX_NAME}' exists.")
209
+
210
+ # Подсчет количества документов в индексе
211
+ count_response = es.count(index=INDEX_NAME)
212
+ print(f"Total documents in '{INDEX_NAME}': {count_response['count']}")
213
+
214
+ def get_elastic_people_query(query):
215
+ has_business_curator = (
216
+ "бизнес куратор" in query.lower() or "бизнес-куратор" in query.lower()
217
+ )
218
+ business_curator_boost = 20 if has_business_curator else 15
219
+ return {
220
+ "query": {
221
+ "function_score": {
222
+ "query": {
223
+ "bool": {
224
+ "should": [
225
+ {
226
+ "multi_match": {
227
+ "query": query,
228
+ "fields": ["person_name^3"],
229
+ "fuzziness": "AUTO",
230
+ "analyzer": "custom_analyzer",
231
+ }
232
+ },
233
+ {
234
+ "nested": {
235
+ "path": "business_processes",
236
+ "query": {
237
+ "multi_match": {
238
+ "query": query,
239
+ "fields": [
240
+ "business_processes.production_activities_section",
241
+ "business_processes.processes_name",
242
+ ],
243
+ "fuzziness": "AUTO",
244
+ "analyzer": "custom_analyzer",
245
+ }
246
+ },
247
+ }
248
+ },
249
+ {
250
+ "nested": {
251
+ "path": "organizatinal_structure",
252
+ "query": {
253
+ "multi_match": {
254
+ "query": query,
255
+ "fields": [
256
+ "organizatinal_structure.position^2"
257
+ ],
258
+ "fuzziness": "AUTO",
259
+ "analyzer": "custom_analyzer",
260
+ }
261
+ },
262
+ }
263
+ },
264
+ {
265
+ "nested": {
266
+ "path": "business_curator",
267
+ "query": {
268
+ "multi_match": {
269
+ "query": query,
270
+ "fields": [
271
+ f"business_curator.company_name^{business_curator_boost}"
272
+ ],
273
+ "fuzziness": "AUTO",
274
+ "analyzer": "custom_analyzer",
275
+ }
276
+ },
277
+ }
278
+ },
279
+ ]
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ query = 'кто бизнес куратор ООО Медвежий ручей?'
287
+ # Выполнение поиска в Elasticsearch
288
+ response = es.search(index=INDEX_NAME, body=get_elastic_people_query(query), size=2)
289
+ logger.info(f"Number of hits: {response['hits']['total']['value']}")
290
+
291
+ # Вывод результата поиска
292
+ for hit in response['hits']['hits']:
293
+ logger.info(hit['_source'])
294
+
295
+
296
+ if __name__ == '__main__':
297
+ path = '/mnt/ntr_work/data/фывфыаыфвфы/person_card'
298
+ create_index_elastic_people(path)
components/elastic/create_index_elastic_abbreviation.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ import pandas as pd
4
+ from elasticsearch import Elasticsearch
5
+ from tqdm import tqdm
6
+
7
+
8
+ def create_index_elastic_abbreviation(
9
+ df: pd.DataFrame,
10
+ logger: logging.Logger | None,
11
+ ):
12
+ if logger is None:
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Подключение к Elasticsearch
16
+ es = Elasticsearch(hosts='localhost:9200')
17
+
18
+ INDEX_NAME = 'nmd_abbreviation_elastic'
19
+
20
+ # Удаление старого индекса, если он существует
21
+ if es.indices.exists(index=INDEX_NAME):
22
+ es.indices.delete(index=INDEX_NAME)
23
+
24
+ mapping = {
25
+ "mappings": {
26
+ "properties": {
27
+ "abbreviation": {"type": "text", "analyzer": "russian"},
28
+ "text": {"type": "text", "analyzer": "russian"},
29
+ }
30
+ }
31
+ }
32
+
33
+ # Создание индекса с указанным маппингом
34
+ es.indices.create(index=INDEX_NAME, body=mapping)
35
+
36
+ # Индексация документов
37
+ for ind, row in tqdm(df.iterrows()):
38
+ document = {'abbreviation': row['name'], 'text': row['definition']}
39
+
40
+ # Индексирование документа в Elasticsearch
41
+ es.index(index=INDEX_NAME, id=ind, body=document)
42
+
43
+ if es.indices.exists(index=INDEX_NAME):
44
+ logger.info(f"Index '{INDEX_NAME}' exists.")
45
+
46
+ # # Подсчет количества документов в индексе
47
+ count_response = es.count(index=INDEX_NAME)
48
+ logger.info(f"Total documents in '{INDEX_NAME}': {count_response['count']}")
49
+
50
+ # Поиск документов, где поле "person_full_name" содержит определенное значение "Александров Д.В."
51
+ query = {
52
+ "query": {
53
+ "multi_match": {
54
+ "query": "для нужен стандарт управления бизнес процессами компании?",
55
+ "fuzziness": "AUTO",
56
+ "minimum_should_match": "83%",
57
+ "fields": ["text"],
58
+ }
59
+ },
60
+ "highlight": {"fields": {"text": {}}},
61
+ }
62
+
63
+ # Выполнение поиска в Elasticsearch
64
+ response = es.search(index=INDEX_NAME, body=query, size=1)
65
+ logger.info(f"Number of hits: {response['hits']['total']['value']}")
66
+
67
+ # Вывод результата поиска
68
+ for hit in response['hits']['hits']:
69
+ logger.info(hit)
70
+ logger.info('=====')
71
+
72
+
73
+ if __name__ == '__main__':
74
+ # Чтение CSV файла с данными
75
+ df = pd.read_csv('/mnt/ntr_work/project/nmd800/data/abbreviations.csv')
76
+
77
+ create_index_elastic_abbreviation(df)
components/elastic/create_index_elastic_chunks.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ import pandas as pd
4
+ from elasticsearch import Elasticsearch
5
+ from tqdm import tqdm
6
+
7
+
8
+ def create_index_elastic_chunks(
9
+ df: pd.DataFrame,
10
+ logger: logging.Logger | None,
11
+ ):
12
+ if logger is None:
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Подключение к Elasticsearch
16
+ es = Elasticsearch(hosts='localhost:9200')
17
+
18
+ INDEX_NAME = 'nmd_full_text2'
19
+
20
+ # Удаление старого индекса, если он существует
21
+ if es.indices.exists(index=INDEX_NAME):
22
+ es.indices.delete(index=INDEX_NAME)
23
+
24
+ mapping = {
25
+ "mappings": {
26
+ "properties": {
27
+ "index": {"type": "keyword"},
28
+ "text": {"type": "text", "analyzer": "standard"},
29
+ }
30
+ }
31
+ }
32
+
33
+ # Создание индекса с указанным маппингом
34
+ es.indices.create(index=INDEX_NAME, body=mapping)
35
+
36
+ # Индексация документов
37
+ for ind, row in tqdm(df.iterrows()):
38
+ document = {'index': ind, 'text': row['Text']}
39
+
40
+ # Индексирование документа в Elasticsearch
41
+ es.index(index=INDEX_NAME, id=ind, body=document)
42
+
43
+ if es.indices.exists(index=INDEX_NAME):
44
+ print(f"Index '{INDEX_NAME}' exists.")
45
+
46
+ # # Подсчет количества документов в индексе
47
+ count_response = es.count(index=INDEX_NAME)
48
+ print(f"Total documents in '{INDEX_NAME}': {count_response['count']}")
49
+
50
+ # Поиск документов, где поле "person_full_name" содержит определенное значение "Александров Д.В."
51
+ query = {
52
+ "query": {
53
+ "multi_match": {
54
+ "query": "4.1. Комиссия ГО имеет право: привлекать работников Компании (по согласованию с руководителями структурных подразделений) для подготовки проектов документов Комиссии ГО, в сроки, установленные Комиссией ГО, а также в целях выполнения других работ, необходимых для принятия решений Комиссии ГО; отклонять материалы, представленные для рассмотрения на заседания Комиссии ГО в случае, если материалы требуют доработки или не относятся к компетенции Комиссии ГО в соответствии с разделом 6 настоящего Положения; \uf02d запрашивать у руководителей структурных подразделений Компании информацию и документы для принятия решений в рамках компетенции Комиссии ГО в соответствии с разделом 6 настоящего Положения; приглашать на заседания Комиссии ГО работников Группы компаний «Норильский никель», представителей Комиссий Филиалов, а также внешних консультантов, экспертов",
55
+ "fields": ["*"],
56
+ }
57
+ }
58
+ }
59
+
60
+ # Выполнение поиска в Elasticsearch
61
+ response = es.search(index=INDEX_NAME, body=query, size=2)
62
+ logger.info(f"Number of hits: {response['hits']['total']['value']}")
63
+
64
+ # Вывод результата поиска
65
+ for hit in response['hits']['hits']:
66
+ logger.info(hit['_source'])
67
+
68
+
69
+ if __name__ == '__main__':
70
+ df = pd.read_pickle(
71
+ '/mnt/ntr_work/project/nmd800/data/db/dataset_local_tables2.pkl'
72
+ )
73
+ create_index_elastic_chunks(df)
components/elastic/create_index_elastic_group.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from elasticsearch import Elasticsearch
7
+ from tqdm import tqdm
8
+
9
+
10
+ def create_index_elastic_group(
11
+ path: str,
12
+ logger: logging.Logger | None = None,
13
+ ):
14
+ if logger is None:
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Подключение к Elasticsearch
18
+ es = Elasticsearch(hosts='localhost:9200')
19
+ INDEX_NAME = 'group_search_elastic_nn'
20
+
21
+ # Удаление старого индекса, если он существует
22
+ if es.indices.exists(index=INDEX_NAME):
23
+ es.indices.delete(index=INDEX_NAME)
24
+
25
+ mapping = {
26
+ "mappings": {
27
+ "properties": {
28
+ "group_name_nn": {"type": "text", "analyzer": "standard"},
29
+ "group_composition_nn": {
30
+ "type": "nested",
31
+ "properties": {
32
+ "person_name_nn": {"type": "text", "analyzer": "standard"},
33
+ "position_in_group_nn": {
34
+ "type": "text",
35
+ "analyzer": "standard",
36
+ },
37
+ },
38
+ },
39
+ }
40
+ }
41
+ }
42
+
43
+ # Создание индекса с указанным маппингом
44
+ es.indices.create(index=INDEX_NAME, body=mapping)
45
+
46
+ for ind, path in tqdm(enumerate(Path(path).iterdir())):
47
+ # Открываем файл и читаем его содержимое
48
+ with open(path, 'r', encoding='utf-8') as file:
49
+ data = json.load(file)
50
+ # Индексирование документа в Elasticsearch
51
+ es.index(index=INDEX_NAME, id=ind + 1, body=data)
52
+
53
+ # Подсчет количества документов в индексе
54
+ count_response = es.count(index=INDEX_NAME)
55
+ logger.info(
56
+ f"{ind}, Total documents in '{INDEX_NAME}': {count_response['count']}"
57
+ )
58
+ time.sleep(0.5)
59
+
60
+ if es.indices.exists(index=INDEX_NAME):
61
+ logger.info(f"Index '{INDEX_NAME}' exists.")
62
+
63
+ # Подсчет количества документов в индексе
64
+ count_response = es.count(index=INDEX_NAME)
65
+ logger.info(f"Total documents in '{INDEX_NAME}': {count_response['count']}")
66
+
67
+ query = "Какие действия являются первоочередными в момент обнаружения происшествия?"
68
+
69
+ # Поиск документов, где поле "person_full_name" содержит определенное значение "Александров Д.В."
70
+ # query_ = {
71
+ # "query": {
72
+ # "function_score": {
73
+ # "query": {
74
+ # "multi_match": {
75
+ # "query": f"{query}",
76
+ # "fields": ["group_name"],
77
+ # "fuzziness": "AUTO",
78
+ # "analyzer": "standard"
79
+ # }
80
+ # },
81
+ # "functions": [
82
+ # {
83
+ # "filter": {
84
+ # "multi_match": {
85
+ # "query": "персонального состава Персональный состав Комитета ПАО ГМК Норильский никель Рабочей группы",
86
+ # "fields": ["group_name"],
87
+ # "operator": "or"
88
+ # }
89
+ # },
90
+ # "weight": 0.9 #// Понижает вес документов с этими словами
91
+ # }
92
+ # ],
93
+ # "boost_mode": "multiply" # // Умножает вес документов с фильтром на указанный коэффициент
94
+ # }
95
+ # }
96
+ # }
97
+ query_ = {
98
+ "query": {
99
+ "bool": {
100
+ "should": [
101
+ {
102
+ "multi_match": {
103
+ "query": f"{query}",
104
+ "fields": ["group_name"],
105
+ "fuzziness": "AUTO",
106
+ "analyzer": "standard",
107
+ }
108
+ },
109
+ {
110
+ "multi_match": {
111
+ "query": "персонального состава Персональный состав Комитета ПАО ГМК Норильский никель Рабочей группы",
112
+ "fields": ["group_name"],
113
+ "operator": "or",
114
+ "boost": 0.1,
115
+ }
116
+ },
117
+ ]
118
+ }
119
+ }
120
+ }
121
+
122
+ # Выполнение поиска в Elasticsearch
123
+ response = es.search(index=INDEX_NAME, body=query_, size=2)
124
+ logger.info(f"Number of hits: {response['hits']['total']['value']}")
125
+
126
+ # Вывод результата поиска
127
+ for hit in response['hits']['hits']:
128
+ logger.info(hit['_source'])
129
+
130
+
131
+ if __name__ == '__main__':
132
+ path = '/mnt/ntr_work/project/nmd800/data/group_card'
133
+ create_index_elastic_group(path)
components/elastic/create_index_elastic_rocks_nn.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ import time
5
+
6
+ from elasticsearch import Elasticsearch
7
+ from tqdm import tqdm
8
+
9
+
10
+ def create_index_elastic_rocks_nn(
11
+ path: str,
12
+ logger: logging.Logger | None = None,
13
+ ):
14
+ if logger is None:
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Подключение к Elasticsearch
18
+ es = Elasticsearch(hosts='localhost:9200')
19
+ INDEX_NAME = 'rocks_nn_search_elastic'
20
+
21
+ # Удаление старого индекса, если он существует
22
+ if es.indices.exists(index=INDEX_NAME):
23
+ es.indices.delete(index=INDEX_NAME)
24
+
25
+ mapping = {
26
+ "settings": {
27
+ "analysis": {
28
+ "filter": {
29
+ "custom_stopwords": {
30
+ "type": "stop",
31
+ "stopwords": [
32
+ "ООО",
33
+ "ОАО",
34
+ "НН",
35
+ "нн",
36
+ "Перечень",
37
+ "перечень",
38
+ "дивизиона",
39
+ "дивизион",
40
+ ],
41
+ }
42
+ },
43
+ "analyzer": {
44
+ "custom_analyzer": {
45
+ "type": "custom",
46
+ "tokenizer": "standard",
47
+ "filter": [
48
+ "lowercase",
49
+ "custom_stopwords",
50
+ ],
51
+ }
52
+ },
53
+ }
54
+ },
55
+ "mappings": {
56
+ "properties": {
57
+ "division_name": {
58
+ "type": "text",
59
+ "analyzer": "custom_analyzer",
60
+ "search_analyzer": "custom_analyzer",
61
+ },
62
+ "division_name_2": {
63
+ "type": "text",
64
+ "analyzer": "custom_analyzer",
65
+ "search_analyzer": "custom_analyzer",
66
+ },
67
+ "company_name": {
68
+ "type": "text",
69
+ "analyzer": "custom_analyzer",
70
+ "search_analyzer": "custom_analyzer",
71
+ },
72
+ }
73
+ },
74
+ }
75
+
76
+ # Создание индекса с указанным маппингом
77
+ es.indices.create(index=INDEX_NAME, body=mapping)
78
+
79
+ for ind, path in tqdm(enumerate(Path(path).iterdir())):
80
+ # Открываем файл и читаем его содержимое
81
+ with open(path, 'r', encoding='utf-8') as file:
82
+ data = json.load(file)
83
+ # Индексирование документа в Elasticsearch
84
+ es.index(index=INDEX_NAME, id=ind + 1, body=data)
85
+
86
+ # Подсчет количества документов в индексе
87
+ count_response = es.count(index=INDEX_NAME)
88
+ logger.info(
89
+ f"{ind}, Total documents in '{INDEX_NAME}': {count_response['count']}"
90
+ )
91
+ time.sleep(1.0)
92
+
93
+ if es.indices.exists(index=INDEX_NAME):
94
+ logger.info(f"Index '{INDEX_NAME}' exists.")
95
+
96
+ # Подсчет количества документов в индексе
97
+ count_response = es.count(index=INDEX_NAME)
98
+ logger.info(f"Total documents in '{INDEX_NAME}': {count_response['count']}")
99
+
100
+ query = "Какие РОКС НН входят в состав Норильского дивизиона?"
101
+
102
+ query_ = {
103
+ "query": {
104
+ "function_score": {
105
+ "query": {
106
+ "multi_match": {
107
+ "query": f"{query}",
108
+ "fields": ["division_name", "division_name_2", "company_name"],
109
+ "fuzziness": "AUTO",
110
+ "analyzer": "custom_analyzer",
111
+ }
112
+ },
113
+ "functions": [
114
+ {
115
+ "filter": {
116
+ "term": {"_id": "3"} # ID документа, который нужно понизить
117
+ },
118
+ "weight": 0.5, # Устанавливает очень низкий вес для этого документа
119
+ }
120
+ ],
121
+ "boost_mode": "multiply", # Сочетание _score и весов
122
+ }
123
+ }
124
+ }
125
+
126
+ # Выполнение поиска в Elasticsearch
127
+ response = es.search(index=INDEX_NAME, body=query_, size=1)
128
+ logger.info(f"Number of hits: {response['hits']['total']['value']}")
129
+
130
+ # Вывод результата поиска
131
+ for hit in response['hits']['hits']:
132
+ logger.info(hit['_source'])
133
+
134
+
135
+ if __name__ == '__main__':
136
+ path = '/mnt/ntr_work/project/nmd800/data/rocks_nn_card'
137
+ create_index_elastic_rocks_nn(path)
components/elastic/create_index_elastic_segmentation.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from elasticsearch import Elasticsearch
7
+ from tqdm import tqdm
8
+
9
+
10
+ def create_index_elastic_segmentation(
11
+ path: str,
12
+ logger: logging.Logger | None = None,
13
+ ):
14
+ if logger is None:
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Подключение к Elasticsearch
18
+ es = Elasticsearch(hosts='localhost:9200')
19
+ INDEX_NAME = 'segmentation_search_elastic'
20
+
21
+ # Удаление старого индекса, если он существует
22
+ if es.indices.exists(index=INDEX_NAME):
23
+ es.indices.delete(index=INDEX_NAME)
24
+
25
+ mapping = {
26
+ "mappings": {
27
+ "properties": {
28
+ "segmentation_model": {"type": "text", "analyzer": "standard"},
29
+ "segmentation_model2": {"type": "text", "analyzer": "standard"},
30
+ "company_name": {"type": "text", "analyzer": "standard"},
31
+ }
32
+ }
33
+ }
34
+
35
+ # Создание индекса с указанным маппингом
36
+ es.indices.create(index=INDEX_NAME, body=mapping)
37
+
38
+ for ind, path in tqdm(enumerate(Path(path).iterdir())):
39
+ # Открываем файл и читаем его содержимое
40
+ with open(path, 'r', encoding='utf-8') as file:
41
+ data = json.load(file)
42
+ # Индексирование документа в Elasticsearch
43
+ es.index(index=INDEX_NAME, id=ind + 1, body=data)
44
+
45
+ # Подсчет количества документов в индексе
46
+ count_response = es.count(index=INDEX_NAME)
47
+ logger.info(
48
+ f"{ind}, Total documents in '{INDEX_NAME}': {count_response['count']}"
49
+ )
50
+ time.sleep(1.0)
51
+
52
+ if es.indices.exists(index=INDEX_NAME):
53
+ logger.info(f"Index '{INDEX_NAME}' exists.")
54
+
55
+ # Подсчет количества документов в индексе
56
+ count_response = es.count(index=INDEX_NAME)
57
+ logger.info(f"Total documents in '{INDEX_NAME}': {count_response['count']}")
58
+
59
+ query = "К какой модели сегментации относится ООО ГРК Быстринское?"
60
+
61
+ query_ = {
62
+ "query": {
63
+ "bool": {
64
+ "should": [
65
+ {
66
+ "multi_match": {
67
+ "query": f"{query}",
68
+ "fields": [
69
+ "segmentation_model",
70
+ "segmentation_model2",
71
+ "company_name",
72
+ ],
73
+ "fuzziness": "AUTO",
74
+ "analyzer": "standard",
75
+ }
76
+ },
77
+ {
78
+ "multi_match": {
79
+ "query": "модели сегментации модель сегментации",
80
+ "fields": ["segmentation_model", "segmentation_model2"],
81
+ "operator": "or",
82
+ "boost": 0.1,
83
+ }
84
+ },
85
+ ]
86
+ }
87
+ }
88
+ }
89
+
90
+ # Выполнение поиска в Elasticsearch
91
+ response = es.search(index=INDEX_NAME, body=query_, size=1)
92
+ logger.info(f"Number of hits: {response['hits']['total']['value']}")
93
+
94
+ # Вывод результата поиска
95
+ for hit in response['hits']['hits']:
96
+ logger.info(hit['_source'])
97
+
98
+
99
+ if __name__ == '__main__':
100
+ path = '/mnt/ntr_work/project/nmd800/data/segmentation_card'
101
+ create_index_elastic_segmentation(path)
components/elastic/elasticsearch_client.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from elasticsearch import Elasticsearch
2
+
3
+ from common.common import get_elastic_query
4
+
5
+
6
+ class ElasticsearchClient:
7
+ def __init__(self,
8
+ host: str = 'localhost',
9
+ port: int = 9200,
10
+ scheme: str = 'http',
11
+ index_name='my_index',
12
+ answer=None):
13
+ """
14
+ Инициализация клиента Elasticsearch и установка имени индекса.
15
+
16
+ Args:
17
+ host: Адрес хоста Elasticsearch
18
+ port:
19
+ scheme:
20
+ index_name: Название индекса, с которым будет работать клиент
21
+ """
22
+ self.es = Elasticsearch([{'host': host, 'port': port, 'scheme': scheme}])
23
+ self.index_name = index_name
24
+ self.answer = answer
25
+
26
+ def set_index(self, index_name):
27
+ """
28
+ Метод для изменения индекса.
29
+
30
+ Args:
31
+ index_name: Название индекса
32
+ """
33
+ self.index_name = index_name
34
+
35
+ def search(self, query, size=10):
36
+ """
37
+ Выполняет поиск по указанному запросу и возвращает результаты.
38
+
39
+ Args:
40
+ query: Запрос для поиска
41
+ size: Максимальное количество возвращаемых результатов
42
+
43
+ Returns:
44
+ Результаты поиска
45
+ """
46
+ response = self.es.search(index=self.index_name, body=query, size=size)
47
+ return response['hits']['hits']
48
+
49
+ def create_document(self, doc_id, document):
50
+ """
51
+ Создает новый документ в Elasticsearch.
52
+
53
+ Args:
54
+ doc_id: Данные документа
55
+ document: Идентификатор документа
56
+ """
57
+ self.es.index(index=self.index_name, id=doc_id, body=document)
58
+
59
+ def get_document(self, doc_id):
60
+ """
61
+ Получает документ по его идентификатору.
62
+
63
+ Args:
64
+ doc_id: Идентификатор документа
65
+
66
+ Returns:
67
+ Найденный документ
68
+ """
69
+ return self.es.get(index=self.index_name, id=doc_id)
70
+
71
+ def delete_document(self, doc_id):
72
+ """
73
+ Удаляет документ по его идентификатору.
74
+
75
+ Args:
76
+ doc_id: Идентификатор документа
77
+ """
78
+ self.es.delete(index=self.index_name, id=doc_id)
79
+
80
+ def update_document(self, doc_id, document):
81
+ """
82
+ Обновляет данные существующего документа.
83
+
84
+ Args:
85
+ doc_id: Идентификатор документа
86
+ document: Обновленные данные документа
87
+ """
88
+ self.es.update(index=self.index_name, id=doc_id, body={"doc": document})
89
+
90
+ def indices(self):
91
+ return self.es.indices.exists(index=self.index_name)
92
+
93
+
94
+ # Пример использования
95
+ if __name__ == "__main__":
96
+ # Инициализация клиента Elasticsearch
97
+ es_client = ElasticsearchClient(index_name='people_search')
98
+
99
+ # Пример запроса для поиска по имени
100
+ search_query = {
101
+ "query": {
102
+ "match": {
103
+ "person_full_name": "Бизнес-куратором каких РОКС НН является Берлин А.В."
104
+ }
105
+ }
106
+ }
107
+
108
+ # Выполнение поиска и вывод результатов
109
+ results = es_client.search(query=get_elastic_query('Бизнес-куратором каких РОКС НН является Берлин А.В.'))
110
+ for result in results:
111
+ print(result['_source'])
components/embedding_extraction.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Callable
3
+
4
+ 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
+
15
+
16
+ class EmbeddingExtractor:
17
+ """Класс обрабатывает текст вопроса и возвращает embedding"""
18
+
19
+ def __init__(
20
+ self,
21
+ model_id: str,
22
+ device: str | torch.device | None = None,
23
+ batch_size: int = 1,
24
+ do_normalization: bool = True,
25
+ max_len: int = 510,
26
+ ):
27
+ """
28
+ Класс, соединяющий в себе модель, токенизатор и параметры векторизации.
29
+
30
+ Args:
31
+ model_id: Идентификатор модели.
32
+ device: Устройство для вычислений (по умолчанию - GPU, если доступен).
33
+ batch_size: Размер батча (по умолчанию - 1).
34
+ do_normalization: Нормировать ли вектора (по умолчанию - True).
35
+ max_len: Максимальная длина текста в токенах (по умолчанию - 510).
36
+ """
37
+ if device is None:
38
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
39
+ else:
40
+ device = torch.device(device)
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()
49
+ self.model.share_memory()
50
+
51
+ self.batch_size = batch_size if device.type != 'cpu' else 1
52
+ self.do_normalization = do_normalization
53
+ self.max_len = max_len
54
+
55
+ @staticmethod
56
+ def _average_pool(
57
+ last_hidden_states: torch.Tensor, attention_mask: torch.Tensor
58
+ ) -> torch.Tensor:
59
+ """
60
+ Расчёт усредненного эмбеддинга по всем токенам
61
+
62
+ Args:
63
+ last_hidden_states: Матрица эмбеддингов отдельных токенов размерности (batch_size, seq_len, embedding_size) - последний скрытый слой
64
+ attention_mask: Маска, чтобы не учитывать при усреднении пустые токены
65
+
66
+ Returns:
67
+ torch.Tensor - Усредненный эмбеддинг размерности (batch_size, embedding_size)
68
+ """
69
+ last_hidden = last_hidden_states.masked_fill(
70
+ ~attention_mask[..., None].bool(), 0.0
71
+ )
72
+ return last_hidden.sum(dim=1) / attention_mask.sum(dim=1)[..., None]
73
+
74
+ def _query_tokenization(self, text: str | list[str]) -> BatchEncoding:
75
+ """
76
+ Преобразует текст в токены.
77
+
78
+ Args:
79
+ text: Текст.
80
+ max_len: Максимальная длина текста (510 токенов)
81
+
82
+ Returns:
83
+ BatchEncoding - Словарь с ключами "input_ids", "attention_mask" и т.п.
84
+ """
85
+ if isinstance(text, str):
86
+ cleaned_text = text.replace('\n', ' ')
87
+ else:
88
+ cleaned_text = [t.replace('\n', ' ') for t in text]
89
+
90
+ return self.tokenizer(
91
+ cleaned_text,
92
+ return_tensors='pt',
93
+ padding=True,
94
+ truncation=True,
95
+ max_length=self.max_len,
96
+ )
97
+
98
+ @torch.no_grad()
99
+ def query_embed_extraction(
100
+ self,
101
+ text: str,
102
+ do_normalization: bool = True,
103
+ ) -> np.ndarray:
104
+ """
105
+ Функция преобразует один текст в эмбеддинг размерности (1, embedding_size)
106
+
107
+ Args:
108
+ text: Текст.
109
+ do_normalization: Нормировать ли вектора embedding
110
+
111
+ Returns:
112
+ np.array - Эмбеддинг размерности (1, embedding_size)
113
+ """
114
+ inputs = self._query_tokenization(text).to(self.device)
115
+ outputs = self.model(**inputs)
116
+
117
+ mask = inputs["attention_mask"]
118
+ embedding = self._average_pool(outputs.last_hidden_state, mask)
119
+
120
+ if do_normalization:
121
+ embedding = F.normalize(embedding, dim=-1)
122
+
123
+ return embedding.cpu().numpy()
124
+
125
+ # TODO: В будущем стоит объединить vectorize и query_embed_extraction
126
+ def vectorize(
127
+ self,
128
+ texts: list[str] | str,
129
+ progress_callback: Callable[[int, int], None] | None = None,
130
+ ) -> np.ndarray:
131
+ """
132
+ Векторизует все тексты в списке.
133
+ Во многом аналогичен методу query_embed_extraction, в будущем стоит объединить их.
134
+
135
+ Args:
136
+ texts: Список текстов или один текст.
137
+ progress_callback: Функция, которая будет вызываться при каждом шаге векторизации.
138
+ Принимает два аргумента: current и total.
139
+ current - текущий шаг векторизации.
140
+ total - общее количество шагов векторизации.
141
+
142
+ Returns:
143
+ np.array - Матрица эмбеддингов размерности (texts_count, embedding_size)
144
+ """
145
+ if isinstance(texts, str):
146
+ texts = [texts]
147
+
148
+ loader = DataLoader(texts, batch_size=self.batch_size)
149
+ embeddings = []
150
+
151
+ logger.info(
152
+ 'Vectorizing texts with batch size %d on %s', self.batch_size, self.device
153
+ )
154
+
155
+ for i, batch in enumerate(loader):
156
+ embeddings.append(self._vectorize_batch(batch))
157
+
158
+ if progress_callback is not None:
159
+ progress_callback(i * self.batch_size, len(texts))
160
+ else:
161
+ logger.info('Vectorized batch %d', i)
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(
169
+ self,
170
+ texts: list[str],
171
+ ) -> torch.Tensor:
172
+ """
173
+ Векторизует один батч текстов.
174
+
175
+ Args:
176
+ texts: Список текстов.
177
+
178
+ Returns:
179
+ torch.Tensor - Матрица эмбеддингов размерности (batch_size, embedding_size)
180
+ """
181
+ tokenized = self._query_tokenization(texts).to(self.device)
182
+ outputs: EncoderOutput = self.model(**tokenized)
183
+ mask = tokenized["attention_mask"]
184
+ embedding = self._average_pool(outputs.last_hidden_state, mask)
185
+
186
+ if self.do_normalization:
187
+ embedding = F.normalize(embedding, dim=-1)
188
+
189
+ return embedding.cpu()
190
+
191
+ def get_dim(self) -> int:
192
+ """
193
+ Возвращает размерность эмбеддинга.
194
+ """
195
+ return self.model.config.hidden_size
components/faiss_vector_database.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Tuple, List, Dict, Union
2
+
3
+ import faiss
4
+ import pandas as pd
5
+ import numpy as np
6
+ import torch
7
+
8
+ from common.constants import COLUMN_DOC_NAME
9
+ from common.constants import COLUMN_EMBEDDING
10
+ from common.constants import COLUMN_LABELS_STR
11
+ from common.constants import COLUMN_NAMES
12
+ from common.constants import COLUMN_TABLE_NAME
13
+ from common.constants import COLUMN_TYPE_DOC_MAP
14
+
15
+
16
+ class FaissVectorDatabase:
17
+ """Класс для взаимодействия между векторами и информацией о них"""
18
+ def __init__(self, path_to_metadata: str = None, df: pd.DataFrame = None, global_df: pd.DataFrame = None):
19
+ if isinstance(df, pd.DataFrame):
20
+ self.df = df
21
+ self.global_df = global_df
22
+ else:
23
+ self.path_to_metadata = path_to_metadata
24
+ self.__load_metadata()
25
+ self.__crate_index()
26
+
27
+ def __load_metadata(self):
28
+ """Load the metadata file."""
29
+ self.df = pd.read_pickle(self.path_to_metadata)
30
+ self.df = self.df.where(pd.notna(self.df), None)
31
+
32
+ def __crate_index(self):
33
+ """Create the faiss index."""
34
+ embeddings = np.array(self.df[COLUMN_EMBEDDING].tolist())
35
+ dim = embeddings.shape[1]
36
+ self.index = faiss.IndexFlatL2(dim)
37
+ self.index.add(embeddings)
38
+
39
+ def _paragraph_content2(self, pattern: str, doc_number: str, ind: int, shape: int) -> Tuple[List, int]:
40
+ """
41
+ Функция возвращает контент параграфа. Если в параграфе были подпункты через "-" или буквы "а, б"
42
+ Args:
43
+ pattern: Паттерн поиска.
44
+ doc_number: Номер документа.
45
+ ind: Индекс строки в DataFrame.
46
+ shape: Размер DataFrame при котором будет возвращаться пустой список.
47
+
48
+ Returns:
49
+ Возвращает список подразделов.
50
+ Examples:
51
+ 3.1. Параграф:
52
+ 1) - Содержание 1;
53
+ 2) - Содержание 2;
54
+ 3) - Содержание 3;
55
+ """
56
+ # TODO: Удалить функцию! Объединить с первой!
57
+ df = self.df[(self.df['DocNumber'] == doc_number) & (self.df['Pargaraph'].str.match(pattern, na=False))]
58
+ if self.df.iloc[ind]['Duplicate'] is not None:
59
+ df = df[df['Duplicate'] == self.df.iloc[ind]['Duplicate']]
60
+ if df.shape[0] <= shape:
61
+ return [], None
62
+ header_text = df.iloc[0]['Text']
63
+ start_index_paragraph = df.index[0]
64
+ paragraphs = []
65
+ for ind2, (_, row) in enumerate(df.iterrows()):
66
+ text = row['Text']
67
+ if ind2 == 0:
68
+ text = text.replace(f'{header_text}', f'{header_text}\n')
69
+ else:
70
+ text = text.replace(f'{header_text}', '') + '\n'
71
+ paragraphs.append(text)
72
+ return paragraphs, start_index_paragraph
73
+
74
+ def _paragraph_content(self, pattern: str, doc_number: str, ind: int, shape: int) -> Tuple[List, int]:
75
+ """
76
+ Функция возвращает контент параграфа. Если в параграфе были подпункты через "-" или буквы "а, б"
77
+ Args:
78
+ pattern: Паттерн поиска.
79
+ doc_number: Номер документа.
80
+ ind: Индекс строки в DataFrame.
81
+ shape: Размер DataFrame при котором будет возвращаться пустой список.
82
+
83
+ Returns:
84
+ Возвращает список подразделов.
85
+ Examples:
86
+ 3.1. Параграф:
87
+ 1) - Содержание 1;
88
+ 2) - Содержание 2;
89
+ 3) - Содержание 3;
90
+ """
91
+ df = self.df[(self.df['DocNumber'] == doc_number) & (self.df['Pargaraph'].str.match(pattern, na=False))]
92
+ if self.df.iloc[ind]['Duplicate'] is not None:
93
+ df = df[df['Duplicate'] == self.df.iloc[ind]['Duplicate']]
94
+ else:
95
+ df = df[df['Duplicate'].isna()]
96
+
97
+ if df.shape[0] <= shape:
98
+ return [], None
99
+ header_text = df.iloc[0]['Text']
100
+ start_index_paragraph = df.index[0]
101
+ paragraphs = []
102
+ for ind2, (_, row) in enumerate(df.iterrows()):
103
+ text = row['Text']
104
+ if ind2 == 0:
105
+ text = text.replace(f'{header_text}', f'{header_text}\n')
106
+ else:
107
+ text = text.replace(f'{header_text}', '') + '\n'
108
+ paragraphs.append(text)
109
+ return paragraphs, start_index_paragraph
110
+
111
+ def _get_top_paragraph(self):
112
+ pass
113
+
114
+ def _search_other_info(self, ind, doc_number):
115
+ other_info = []
116
+ start_index_paragraph = []
117
+
118
+ if self.df.iloc[ind]['PartLevel1'] is not None:
119
+ if 'Table' in str(self.df.iloc[ind]['PartLevel1']):
120
+ return [], ind
121
+
122
+ if self.df.iloc[ind]['Appendix'] is not None:
123
+ df = self.df[(self.df['DocNumber'] == doc_number) & (self.df['Appendix'] == self.df.iloc[ind]['Appendix'])]
124
+ other_info.append(f'{df.loc[ind]["Text"]}')
125
+ return other_info, ind
126
+ else:
127
+ if self.df.iloc[ind]['Pargaraph'] is None:
128
+ other_info.append(f'{self.df.iloc[ind]["Text"]}')
129
+ else:
130
+ pattern = self.df.iloc[ind]["Pargaraph"].replace(".", r"\.")
131
+ paragraph, start_index_paragraph = self._paragraph_content(fr'^{pattern}?$', doc_number, ind, 1)
132
+ if 'Компания обязуется в области охраны труда' in pattern:
133
+ other_info.append(f'{self.df.iloc[ind + 1]["Text"]}')
134
+ # TODO Баг который нужно исправить!!!! Связан с документами без пунктов
135
+ if not paragraph and self.df.iloc[ind]['LevelParagraph'] != '0':
136
+ pattern = self.df.iloc[ind]["Pargaraph"]
137
+ pattern = pattern.split('.')
138
+ pattern = [elem for elem in pattern if elem]
139
+ pattern = '.'.join(pattern[:-1])
140
+ pattern = f'^{pattern}\\.\\d.?$'
141
+ paragraph, start_index_paragraph = self._paragraph_content2(pattern, doc_number, ind, 0)
142
+ elif not paragraph and self.df.iloc[ind]['LevelParagraph'] == '0':
143
+ pattern = self.df.iloc[ind]["Pargaraph"].replace(".", r"\.")
144
+ if '.' not in pattern:
145
+ pattern = pattern + '\.'
146
+ pattern = f'^{pattern}\\d.?$'
147
+ paragraph, start_index_paragraph = self._paragraph_content2(pattern, doc_number, ind, 0)
148
+ other_info.append(' '.join(paragraph))
149
+
150
+ return other_info, start_index_paragraph
151
+
152
+ def search(self, emb_query: torch.Tensor, k_neighbors: int, other_information: bool) -> dict:
153
+ """
154
+ Метод ищет ответы на запрос
155
+ Args:
156
+ emb_query: Embedding вопроса.
157
+ k_neighbors: Количество ближайших ответов к вопросу.
158
+ other_information:
159
+
160
+ Returns:
161
+ Возвращает словарь с ответами и информацией об ответах.
162
+ """
163
+ if len(emb_query.shape) != 2:
164
+ assert print('Не правильный размер вектора!')
165
+
166
+ distances, indexes = self.index.search(emb_query, k_neighbors)
167
+ answers = {}
168
+ for i, ind in enumerate(indexes[0]):
169
+ answers[i] = {}
170
+ answers[i][f'distance'] = float(distances[0][i])
171
+ answers[i][f'index_answer'] = int(ind)
172
+ answers[i][f'doc_name'] = self.df.iloc[ind]['DocName']
173
+ # answers[i][f'title'] = self.df.iloc[ind]['Title']
174
+ answers[i][f'text_answer'] = self.df.iloc[ind]['Text']
175
+ doc_number = self.df.iloc[ind]['DocNumber']
176
+
177
+ if other_information:
178
+ other_info, start_index_paragraph = self._search_other_info(ind, doc_number)
179
+ answers[i][f'other_info'] = other_info
180
+ answers[i][f'start_index_paragraph'] = start_index_paragraph
181
+ return answers
182
+
183
+ def search_transaction_map(self, emb_query: torch.Tensor, k_neighbors: int) -> Dict[str, Union[str, int]]:
184
+ """
185
+ Метод ищет ответы на запрос по картам проводок
186
+ Args:
187
+ emb_query: Embedding вопроса.
188
+ k_neighbors: Количество ближайших ответов к вопросу.
189
+
190
+ Returns:
191
+ Возвращает словарь с ответами и информацией об ответах.
192
+
193
+ Notes:
194
+ Будет возвращаться словарь вида
195
+ {
196
+ 'distance': Дистанция между векторами
197
+ 'index_answer': Индекс ответа как в df index
198
+ 'doc_name': Наименование документа
199
+ 'text_answer': Название таблицы / Названия файла
200
+ 'labels': Метка для расчета метрик
201
+ 'Columns': Наименования колонок в карте проводок
202
+ 'TypeDocs': К кому разделу относится карта проводок (1С или SAP)
203
+ }
204
+ """
205
+ if len(emb_query.shape) != 2:
206
+ assert print('Не правильный размер вектора!')
207
+
208
+ distances, indexes = self.index.search(emb_query, k_neighbors)
209
+ answers = {}
210
+ for i, ind in enumerate(indexes[0]):
211
+ answers[i] = {}
212
+ answers[i][f'distance'] = distances[0][i]
213
+ answers[i][f'index_answer'] = ind
214
+ answers[i][f'doc_name'] = self.df.iloc[ind][COLUMN_DOC_NAME]
215
+ answers[i][f'text_answer'] = self.df.iloc[ind][COLUMN_TABLE_NAME]
216
+ answers[i][COLUMN_LABELS_STR] = self.df.iloc[ind][COLUMN_LABELS_STR]
217
+ answers[i][COLUMN_NAMES] = self.df.iloc[ind][COLUMN_NAMES]
218
+ answers[i][COLUMN_TYPE_DOC_MAP] = self.df.iloc[ind][COLUMN_TYPE_DOC_MAP]
219
+
220
+ return answers
221
+
222
+ def search_by_group_and_person(self, emb_query: torch.Tensor, query: str, k_neighbors: int) -> Dict[str, Union[str, int]]:
223
+ if len(emb_query.shape) != 2:
224
+ assert print('Не правильный размер вектора!')
225
+ answers = {}
226
+
227
+ for i, name in enumerate(self.global_df['ФИО'].unique()):
228
+ if name in query or name.split(' ')[0] in query:
229
+ answers[i] = {}
230
+ df = self.global_df[self.global_df['ФИО'] == name]
231
+ answers[i][f'name'] = name
232
+ answers[i][f'position'] = df['Должность'].unique()
233
+ answers[i][f'group'] = df['Группа'].unique()
234
+ answers[i][f'position_in_group'] = df['Должность внутри группы'].unique()
235
+ return answers
236
+
237
+ distances, indexes = self.index.search(emb_query, k_neighbors)
238
+ for i, ind in enumerate(indexes[0]):
239
+ answers[i] = {}
240
+ unique_value = self.df.iloc[ind]['unique_value']
241
+ df = self.global_df[(self.global_df['Должность'] == unique_value) | (self.global_df['Группа'] == unique_value)]
242
+
243
+ answers[i][f'name'] = df['ФИО'].unique()
244
+ answers[i][f'position'] = df['Должность'].unique()
245
+ answers[i][f'group'] = df['Группа'].unique()
246
+ answers[i][f'position_in_group'] = df['Должность внутри группы'].unique()
247
+
248
+ return answers
components/llm/common.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, List, Protocol
3
+
4
+ class LlmPredictParams(BaseModel):
5
+ """
6
+ Параметры для предсказания LLM.
7
+ """
8
+ system_prompt: Optional[str] = Field(None, description="Системный промпт.")
9
+ user_prompt: Optional[str] = Field(None, description="Шаблон промпта для передачи от роли user.")
10
+ n_predict: Optional[int] = None
11
+ temperature: Optional[float] = None
12
+ top_k: Optional[int] = None
13
+ top_p: Optional[float] = None
14
+ min_p: Optional[float] = None
15
+ seed: Optional[int] = None
16
+ repeat_penalty: Optional[float] = None
17
+ repeat_last_n: Optional[int] = None
18
+ retry_if_text_not_present: Optional[str] = None
19
+ retry_count: Optional[int] = None
20
+ presence_penalty: Optional[float] = None
21
+ frequency_penalty: Optional[float] = None
22
+ n_keep: Optional[int] = None
23
+ cache_prompt: Optional[bool] = None
24
+ stop: Optional[List[str]] = None
25
+
26
+
27
+ class LlmParams(BaseModel):
28
+ """
29
+ Основные параметры для LLM.
30
+ """
31
+ url: str
32
+ model: Optional[str] = Field(None, description="Предполагается, что для локального API этот параметр не будет указываться, т.к. будем брать первую модель из списка потому, что модель доступна всего одна. Для deepinfra такой подход не подойдет и модель нужно задавать явно.")
33
+ tokenizer: Optional[str] = Field(None, description="При использовании стороннего API, не поддерживающего токенизацию, будет использован AutoTokenizer для модели из этого поля. Используется в случае, если название модели в API не совпадает с оригинальным названием на Huggingface.")
34
+ type: Optional[str] = None
35
+ default: Optional[bool] = None
36
+ template: Optional[str] = None
37
+ predict_params: Optional[LlmPredictParams] = None
38
+ api_key: Optional[str] = None
39
+ context_length: Optional[int] = None
40
+
41
+ class LlmApiProtocol(Protocol):
42
+ async def tokenize(self, prompt: str) -> Optional[dict]:
43
+ ...
44
+ async def detokenize(self, tokens: List[int]) -> Optional[str]:
45
+ ...
46
+ async def trim_sources(self, sources: str, user_request: str, system_prompt: str = None) -> dict:
47
+ ...
48
+ async def predict(self, prompt: str) -> str:
49
+ ...
50
+
51
+ class LlmApi:
52
+ """
53
+ Базовый клас для работы с API LLM.
54
+ """
55
+ params: LlmParams = None
56
+
57
+ def __init__(self):
58
+ self.params = None
59
+
60
+ def set_params(self, params: LlmParams):
61
+ self.params = params
62
+
63
+ def create_headers(self) -> dict[str, str]:
64
+ headers = {"Content-Type": "application/json"}
65
+
66
+ if self.params.api_key is not None:
67
+ headers["Authorization"] = self.params.api_key
68
+
69
+ return headers
70
+
71
+
72
+ class Message(BaseModel):
73
+ role: str
74
+ content: str
75
+ searchResults: List[str]
76
+
77
+ class ChatRequest(BaseModel):
78
+ history: List[Message]
components/llm/deepinfra_api.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Optional, List
3
+ import httpx
4
+ import logging
5
+ from transformers import AutoTokenizer
6
+ from components.llm.utils import convert_to_openai_format
7
+ from components.llm.common import ChatRequest, LlmParams, LlmApi, LlmPredictParams
8
+
9
+ logging.basicConfig(
10
+ level=logging.DEBUG,
11
+ format="%(asctime)s - %(message)s",
12
+ )
13
+
14
+ class DeepInfraApi(LlmApi):
15
+ """
16
+ Класс для работы с API vllm.
17
+ """
18
+
19
+ def __init__(self, params: LlmParams):
20
+ super().__init__()
21
+ super().set_params(params)
22
+ print('Tokenizer initialization.')
23
+ # self.tokenizer = AutoTokenizer.from_pretrained(params.tokenizer if params.tokenizer is not None else params.model)
24
+ print(f"Tokenizer initialized for model {params.model}.")
25
+
26
+ async def get_models(self) -> List[str]:
27
+ """
28
+ Выполняет GET-запрос к API для получения списка доступных моделей.
29
+
30
+ Возвращает:
31
+ list[str]: Список идентификаторов моделей.
32
+ Если произошла ошибка или данные недоступны, возвращается пустой список.
33
+
34
+ Исключения:
35
+ Все ошибки HTTP-запросов логируются в консоль, но не выбрасываются дальше.
36
+ """
37
+ try:
38
+ async with httpx.AsyncClient() as client:
39
+ response = await client.get(f"{self.params.url}/v1/openai/models", headers=super().create_headers())
40
+ if response.status_code == 200:
41
+ json_data = response.json()
42
+ return [item['id'] for item in json_data.get('data', [])]
43
+ except httpx.RequestError as error:
44
+ print('Error fetching models:', error)
45
+ return []
46
+
47
+ def create_messages(self, prompt: str, system_prompt: str = None) -> List[dict]:
48
+ """
49
+ Создает сообщения для LLM на основе переданного промпта и системного промпта (если он задан).
50
+
51
+ Args:
52
+ prompt (str): Пользовательский промпт.
53
+
54
+ Returns:
55
+ list[dict]: Список сообщений с ролями и содержимым.
56
+ """
57
+ actual_prompt = self.apply_llm_template_to_prompt(prompt)
58
+ messages = []
59
+
60
+ if system_prompt is not None:
61
+ messages.append({"role": "system", "content": system_prompt})
62
+ else:
63
+ if self.params.predict_params and self.params.predict_params.system_prompt:
64
+ messages.append({"role": "system", "content": self.params.predict_params.system_prompt})
65
+ messages.append({"role": "user", "content": actual_prompt})
66
+ return messages
67
+
68
+ def apply_llm_template_to_prompt(self, prompt: str) -> str:
69
+ """
70
+ Применяет шаблон LLM к переданному промпту, если он задан.
71
+
72
+ Args:
73
+ prompt (str): Пользовательский промпт.
74
+
75
+ Returns:
76
+ str: Промпт с примененным шаблоном (или оригинальный, если шаблон отсутствует).
77
+ """
78
+ actual_prompt = prompt
79
+ if self.params.template is not None:
80
+ actual_prompt = self.params.template.replace("{{PROMPT}}", actual_prompt)
81
+ return actual_prompt
82
+
83
+ async def tokenize(self, prompt: str) -> Optional[dict]:
84
+ """
85
+ Токенизирует входной текстовый промпт.
86
+
87
+ Args:
88
+ prompt (str): Текст, который нужно токенизировать.
89
+ Returns:
90
+ dict: Словарь с токенами и их количеством или None в случае ошибки.
91
+ """
92
+ try:
93
+ tokens = self.tokenizer.encode(prompt, add_special_tokens=True)
94
+
95
+ return {"result": tokens, "num_tokens": len(tokens), "max_length": self.params.context_length}
96
+ except Exception as e:
97
+ print(f"Tokenization error: {e}")
98
+ return None
99
+
100
+ async def detokenize(self, tokens: List[int]) -> Optional[str]:
101
+ """
102
+ Детокенизирует список токенов обратно в строку.
103
+
104
+ Args:
105
+ tokens (List[int]): Список токенов, который нужно преобразовать в текст.
106
+ Returns:
107
+ str: Восстановленный текст или None в случае ошибки.
108
+ """
109
+ try:
110
+ text = self.tokenizer.decode(tokens, skip_special_tokens=True)
111
+ return text
112
+ except Exception as e:
113
+ print(f"Detokenization error: {e}")
114
+ return None
115
+
116
+
117
+ def create_chat_request(self, chat_request: ChatRequest, system_prompt, params: LlmPredictParams) -> dict:
118
+ """
119
+ Создает запрос для предсказания на основе параметров LLM.
120
+
121
+ Args:
122
+ prompt (str): Промпт для запроса.
123
+
124
+ Returns:
125
+ dict: Словарь с параметрами для выполнения запроса.
126
+ """
127
+
128
+ request = {
129
+ "stream": False,
130
+ "model": self.params.model,
131
+ }
132
+
133
+ predict_params = params
134
+ if predict_params:
135
+ if predict_params.stop:
136
+ non_empty_stop = list(filter(lambda o: o != "", predict_params.stop))
137
+ if non_empty_stop:
138
+ request["stop"] = non_empty_stop
139
+
140
+ if predict_params.n_predict is not None:
141
+ request["max_tokens"] = int(predict_params.n_predict or 0)
142
+
143
+ request["temperature"] = float(predict_params.temperature or 0)
144
+ if predict_params.top_k is not None:
145
+ request["top_k"] = int(predict_params.top_k)
146
+
147
+ if predict_params.top_p is not None:
148
+ request["top_p"] = float(predict_params.top_p)
149
+
150
+ if predict_params.min_p is not None:
151
+ request["min_p"] = float(predict_params.min_p)
152
+
153
+ if predict_params.seed is not None:
154
+ request["seed"] = int(predict_params.seed)
155
+
156
+ if predict_params.n_keep is not None:
157
+ request["n_keep"] = int(predict_params.n_keep)
158
+
159
+ if predict_params.cache_prompt is not None:
160
+ request["cache_prompt"] = bool(predict_params.cache_prompt)
161
+
162
+ if predict_params.repeat_penalty is not None:
163
+ request["repetition_penalty"] = float(predict_params.repeat_penalty)
164
+
165
+ if predict_params.repeat_last_n is not None:
166
+ request["repeat_last_n"] = int(predict_params.repeat_last_n)
167
+
168
+ if predict_params.presence_penalty is not None:
169
+ request["presence_penalty"] = float(predict_params.presence_penalty)
170
+
171
+ if predict_params.frequency_penalty is not None:
172
+ request["frequency_penalty"] = float(predict_params.frequency_penalty)
173
+
174
+ request["messages"] = convert_to_openai_format(chat_request, system_prompt)
175
+ return request
176
+
177
+ async def create_request(self, prompt: str, system_prompt: str = None) -> dict:
178
+ """
179
+ Создает запрос для предсказания на основе параметров LLM.
180
+
181
+ Args:
182
+ prompt (str): Промпт для запроса.
183
+
184
+ Returns:
185
+ dict: Словарь с параметрами для выполнения запроса.
186
+ """
187
+
188
+ request = {
189
+ "stream": False,
190
+ "model": self.params.model,
191
+ }
192
+
193
+ predict_params = self.params.predict_params
194
+ if predict_params:
195
+ if predict_params.stop:
196
+ non_empty_stop = list(filter(lambda o: o != "", predict_params.stop))
197
+ if non_empty_stop:
198
+ request["stop"] = non_empty_stop
199
+
200
+ if predict_params.n_predict is not None:
201
+ request["max_tokens"] = int(predict_params.n_predict or 0)
202
+
203
+ request["temperature"] = float(predict_params.temperature or 0)
204
+ if predict_params.top_k is not None:
205
+ request["top_k"] = int(predict_params.top_k)
206
+
207
+ if predict_params.top_p is not None:
208
+ request["top_p"] = float(predict_params.top_p)
209
+
210
+ if predict_params.min_p is not None:
211
+ request["min_p"] = float(predict_params.min_p)
212
+
213
+ if predict_params.seed is not None:
214
+ request["seed"] = int(predict_params.seed)
215
+
216
+ if predict_params.n_keep is not None:
217
+ request["n_keep"] = int(predict_params.n_keep)
218
+
219
+ if predict_params.cache_prompt is not None:
220
+ request["cache_prompt"] = bool(predict_params.cache_prompt)
221
+
222
+ if predict_params.repeat_penalty is not None:
223
+ request["repetition_penalty"] = float(predict_params.repeat_penalty)
224
+
225
+ if predict_params.repeat_last_n is not None:
226
+ request["repeat_last_n"] = int(predict_params.repeat_last_n)
227
+
228
+ if predict_params.presence_penalty is not None:
229
+ request["presence_penalty"] = float(predict_params.presence_penalty)
230
+
231
+ if predict_params.frequency_penalty is not None:
232
+ request["frequency_penalty"] = float(predict_params.frequency_penalty)
233
+
234
+ request["messages"] = self.create_messages(prompt, system_prompt)
235
+ return request
236
+
237
+ async def trim_sources(self, sources: str, user_request: str, system_prompt: str = None) -> dict:
238
+ raise NotImplementedError("This function is not supported.")
239
+
240
+ async def predict_chat(self, request: ChatRequest, system_prompt, params: LlmPredictParams) -> str:
241
+ """
242
+ Выполняет запрос к API и возвращает результат.
243
+
244
+ Args:
245
+ prompt (str): Входной текст для предсказания.
246
+
247
+ Returns:
248
+ str: Сгенерированный текст.
249
+ """
250
+ async with httpx.AsyncClient() as client:
251
+ request = self.create_chat_request(request, system_prompt, params)
252
+ response = await client.post(f"{self.params.url}/v1/openai/chat/completions", headers=super().create_headers(), json=request, timeout=httpx.Timeout(connect=5.0, read=60.0, write=180, pool=10))
253
+ if response.status_code == 200:
254
+ return response.json()["choices"][0]["message"]["content"]
255
+ else:
256
+ logging.error(f"Request failed: status code {response.status_code}")
257
+ logging.error(response.text)
258
+
259
+ async def predict_chat_stream(self, request: ChatRequest, system_prompt, params: LlmPredictParams) -> str:
260
+ """
261
+ Выполняет запрос к API с поддержкой потокового вывода (SSE) и возвращает результат.
262
+
263
+ Args:
264
+ prompt (str): Входной текст для предсказания.
265
+
266
+ Returns:
267
+ str: Сгенерированный текст.
268
+ """
269
+ async with httpx.AsyncClient() as client:
270
+ request = self.create_chat_request(request, system_prompt, params)
271
+ request["stream"] = True
272
+
273
+ print(super().create_headers())
274
+ async with client.stream("POST", f"{self.params.url}/v1/openai/chat/completions", json=request, headers=super().create_headers()) as response:
275
+ if response.status_code != 200:
276
+ # Если ошибка, читаем ответ для получения подробностей
277
+ error_content = await response.aread()
278
+ raise Exception(f"API error: {error_content.decode('utf-8')}")
279
+
280
+ # Для хранения результата
281
+ generated_text = ""
282
+
283
+ # Асинхронное чтение построчно
284
+ async for line in response.aiter_lines():
285
+ if line.startswith("data: "): # SSE-сообщения начинаются с "data: "
286
+ try:
287
+ # Парсим JSON из строки
288
+ data = json.loads(line[len("data: "):].strip())
289
+ print(data)
290
+ if data == "[DONE]": # Конец потока
291
+ break
292
+ if "choices" in data and data["choices"]:
293
+ # Получаем текст из текущего токена
294
+ token_value = data["choices"][0].get("delta", {}).get("content", "")
295
+ generated_text += token_value
296
+ except json.JSONDecodeError:
297
+ continue # Игнорируем строки, которые не удается декодировать
298
+
299
+ return generated_text.strip()
300
+
301
+ async def predict(self, prompt: str, system_prompt: str) -> str:
302
+ """
303
+ Выполняет запрос к API и возвращает результат.
304
+
305
+ Args:
306
+ prompt (str): Входной текст для предсказания.
307
+
308
+ Returns:
309
+ str: Сгенерированный текст.
310
+ """
311
+ async with httpx.AsyncClient() as client:
312
+ request = await self.create_request(prompt, system_prompt)
313
+ response = await client.post(f"{self.params.url}/v1/openai/chat/completions", headers=super().create_headers(), json=request, timeout=httpx.Timeout(connect=5.0, read=60.0, write=180, pool=10))
314
+ if response.status_code == 200:
315
+ return response.json()["choices"][0]["message"]["content"]
316
+ else:
317
+ logging.info(f"Request {prompt} failed: status code {response.status_code}")
318
+ logging.info(response.text)
319
+
320
+ async def trim_prompt(self, prompt: str, system_prompt: str = None):
321
+
322
+ result = await self.tokenize(prompt)
323
+
324
+ result_system = None
325
+ system_prompt_length = 0
326
+ if system_prompt is not None:
327
+ result_system = await self.tokenize(system_prompt)
328
+
329
+ if result_system is not None:
330
+ system_prompt_length = len(result_system["result"])
331
+
332
+
333
+ # в случае ошибки при токенизации, вернем исходную строку безопасной длины
334
+ if result["result"] is None or (system_prompt is not None and result_system is None):
335
+ return prompt[int(self.params.context_length / 3)]
336
+
337
+ #вероятно, часть уходит на форматирование чата, надо проверить
338
+ max_length = result["max_length"] - len(result["result"]) - system_prompt_length - self.params.predict_params.n_predict
339
+
340
+ detokenized_str = await self.detokenize(result["result"][:max_length])
341
+
342
+ # в случае ошибки при детокенизации, вернем исходную строку безопасной длины
343
+ if detokenized_str is None:
344
+ return prompt[self.params.context_length / 3]
345
+
346
+ return detokenized_str
components/llm/llm_api.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from threading import Lock
3
+ from components.llm.common import LlmParams, LlmPredictParams
4
+ from components.llm.deepinfra_api import DeepInfraApi
5
+
6
+ class LlmApi:
7
+ _instance = None
8
+ _lock = Lock()
9
+
10
+ def __new__(cls):
11
+ with cls._lock:
12
+ if cls._instance is None:
13
+ cls._instance = super(LlmApi, cls).__new__(cls)
14
+ cls._instance._initialize()
15
+ return cls._instance
16
+
17
+ def _initialize(self):
18
+ LLM_API_URL = os.getenv("LLM_API_URL", "https://api.deepinfra.com")
19
+ LLM_API_KEY = os.getenv("DEEPINFRA_API_KEY", "")
20
+ LLM_NAME = os.getenv("LLM_NAME", "meta-llama/Llama-3.3-70B-Instruct-Turbo")
21
+ TOKENIZER_NAME = os.getenv("TOKENIZER_NAME", "unsloth/Llama-3.3-70B-Instruct")
22
+
23
+ default_llm_params = LlmParams(
24
+ url=LLM_API_URL,
25
+ api_key=LLM_API_KEY,
26
+ model=LLM_NAME,
27
+ tokenizer=TOKENIZER_NAME,
28
+ context_length=130000,
29
+ predict_params=LlmPredictParams(
30
+ temperature=0.15, top_p=0.95, min_p=0.05, seed=42,
31
+ repetition_penalty=1.2, presence_penalty=1.1, n_predict=6000
32
+ )
33
+ )
34
+ self.api = DeepInfraApi(default_llm_params)
35
+
36
+ def get_api(self):
37
+ return self.api
components/llm/prompts.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SYSTEM_PROMPT = """
2
+ Ты профессиональный банковский рекрутёр
3
+ ####
4
+ Инструкция для составления ответа
5
+ ####
6
+ Твоя задача - ответить максимально корректно на запрос пользователя по теме рекрутинга, используя информацию по запросу. Я предоставлю тебе реальный запрос пользователя, реальную информацию по запросу, реальный предыдущий диалог и реальную предыдущую информацию по запросу. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
7
+ - Отвечай ТОЛЬКО на русском языке.
8
+ - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
9
+ - Запрещено писать транслитом. Запрещено писать на языках не русском.
10
+ - Тебе запрещено самостоятельно расшифровывать аббревиатуры.
11
+ - Будь вежливым и дружелюбным.
12
+ - Запрещено выдумывать. Если какой-то информации для ответа на запрос не хватает, то запрещено самостоятельно её придумывать.
13
+ - Уточняй вопрос, если тебе не хватает информации. Попроси переформулировать или уточнить какие-то конкретные детали у пользователя. Если пользователь уточнит запрос, то в источниках появится новая информация по запросу с помощью которой ты сможешь ответить.
14
+ - Отвечай только на запрос пользователя.
15
+ - Если есть противоречие в информации, то укажи на это в своём ответе.
16
+ - Если пользователь спрашивает у тебя кто ты, ответь что ты профессиональный рекрутёр.
17
+ - Если запрос требует рассуждений, то напиши свои рассуждения перед формированием ответа.
18
+ - Если запрос пользоваля бессмысленный, то вежливо ответь чтобы пользователь сформулировал его более корректно.
19
+ - Не используй информацию из примеров, они только показывают правильную логику формирования твоего ответа на основе полученной информации.
20
+ - Если пользователь не просит, то запрещено указывать источники информации.
21
+ - Нужно обязательно ответить на все вопросы пользователя.
22
+ Конец основных правил. Ты действуешь по плану:
23
+ 1. Изучи информацию по запросу, предыдущую информацию по запросу и предыдущий диалог.
24
+ 2. Напиши новый ответ на новый запрос.
25
+ Конец плана.
26
+ Структура общения:
27
+ user - это сообщения пользователя.
28
+ <search-results>Вот тут будет найденная информация по запросу пользователя</search-results>
29
+ assistant - это твои ответы на запросы пользователя.
30
+ ####
31
+ Пример 1
32
+ ####
33
+ user: А в какие сроки на меня нужно направить характеристику для аттестации?
34
+ <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
35
+ Характеристика на работника, подлежащего аттестации, вместе с копией должностной инструкции представляется в аттестационную комиссию не позднее чем за 10 дней до начала аттестации.</search-results>
36
+ assistant: Не позднее чем за 10 дней до начала аттестации в аттестационную комиссию нужно направить характеристику вместе с копией должностной инструкции.
37
+ user: Я волнуюсь. А как она проводится?
38
+ <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
39
+ 12-1. Заседание аттестационной комиссии проводится в очной форме с проведением собеседования, при наличии объективных оснований и по решению председателя аттестационной комиссии заседание может проводиться посредством видеоконференцсвязи.
40
+ (п. 12-1 введен Решением Правления ОАО "Белагропромбанк" от 24.09.2020 N 80)
41
+ 13. Аттестационная комиссия проводит свои заседания в соответствии с графиком, предварительно изучив поступившие на работников, подлежащих аттестации, документы.
42
+ На заседании комиссии ведется протокол, который подписывается председателем и секретарем комиссии, являющимися одновременно членами комиссии с правом голоса.</search-results>
43
+ assistant: Не переживайте. Аттестация проводится в очной форме в виде собеседования. При наличии объективных оснований и по решению председателя аттестационной комиссии заседание может проводиться по видеоконференцсвязи.
44
+ user: А кто будет участвовать?
45
+ <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
46
+ 15. Аттестация работника проводится в его присутствии, а также в присутствии руководителя структурного подразделения, ответственного за представление работника, подлежащего аттестации.</search-results>
47
+ ####
48
+ assistant: На аттестации присутствуют:
49
+ - работник, который проходит аттестацию;
50
+ - руководитель структурного подразделения, отвественный за представление работника;
51
+ - аттестационная комиссия.
52
+ ####
53
+ Пример 2
54
+ ####
55
+ user: Здравствуйте. Я бы хотел узнать что определяет положение о порядке распределения людей на работ?
56
+ <search-results>[Источник] - Положение о порядке распределения, перераспределения, направления на работу.
57
+ 1. Настоящим Положением определяется порядок:
58
+ Распределения, перераспределения, направления на работу, перенаправления на работу выпускников государственных учреждений образования, государственных организаций, реализующих образовательные программы научно-ориентированного образования (далее, если не указано иное, - учреждения образования);
59
+ Предоставления места работы гражданам Республики Беларусь, получившим в дневной форме получения образования научно-ориентированное, высшее, среднее специальное или профессионально-техническое образование в иностранных организациях;</search-results>
60
+ ####
61
+ assistant: Здравствуйте. Положение о порядке распределения, перераспределения и направления на работу определяет порядок:
62
+ - распределения, перераспределения, направления на работу, перенаправления на работу выпускников государственных учреждений образования, государственных организаций, реализующих образовательные программы научно-ориентированного образования (далее, если не указано иное, - учреждения образования);
63
+ - предоставления места работы гражданам Республики Беларусь, получившим в дневной форме получения образования научно-ориентированное, высшее, среднее специальное или профессионально-техническое образование в иностранных организациях;
64
+ ####
65
+ Пример 3
66
+ ####
67
+ user: Привет! Кто ты?
68
+ <search-results></search-results>
69
+ assistant: Я профессиональный помощник рекрутёра. Вы можете задавать мне любые вопросы по подготовленным документам.
70
+ user: А если я задам вопрос не по документам? Ты мне наврёшь?
71
+ <search-results></search-results>
72
+ assistant: Нет, что вы. Я формирую ответ только по найденной из документов информации. Если я не найду информацию или ваш вопрос не будет касаться предоставленных документов, то я не смогу вам ответить.
73
+ user: Где питается слон?
74
+ <search-results></search-results>
75
+ assistant: Извините, я не знаю ответ на этот вопрос. Он не касается рекрутинга. Попробуйте переформулировать.
76
+ user: Что такое корпоративное управление банка? Зачем нужны комитеты? Где собака зарыта? Откуда ты всё знаешь?
77
+ <search-results>[Источник] ИНСТРУКЦИЯ ОБ ОРГАНИЗАЦИИ КОРПОРАТИВНОГО УПРАВЛЕНИЯ
78
+ 3. Под корпоративным управлением банком понимается система взаимодействия акционеров, органов управления, контрольных органов, должностных лиц банка и иных заинтересованных лиц, направленная на общее руководство деятельностью банка.
79
+ ...
80
+ 15. Основной задачей комитетов, создаваемых советом директоров, является обеспечение качественной подготовки решений совета директоров по вопросам, относящимся к их компетенции, путем углубленного изучения данных вопросов и выработки необходимых рекомендаций.
81
+ Комитеты в рамках вопросов, относящихся к их компетенции:
82
+ анализируют процесс реализации целей и стратегии развития банка, выполнения решений совета директоров;
83
+ представляют на рассмотрение совета директоров свои рекомендации и регулярные отчеты о деятельности банка.</search-results>
84
+ ####
85
+ assistant: Вы задали несколько вопросов и я отвечу на каждый из них отдельно:
86
+ 1. Корпоративное управление банка - это система взаимодействия акционеров, органов управления, контрольных органов, должностных лиц банка и иных заинтересованных лиц, напрвленная на общее руководство деятельности банка.
87
+ 2. Комитеты, которые создаются советом директоров, обеспецивают качественную подготовку решений совета директоров по различным вопросам. Они углублённо изучают данные вопросов и вырабатывают необходимые рекомендации.
88
+ 3. Извините, я не знаю как ответить на этот вопрос. Он не касается темы рекрутинга или я не совсем понимаю его контекст.
89
+ 4. Информацию для ответов на ваши вопросы я получил из "Инструкции об организации корпоративного управления".
90
+ ####
91
+ Далее будет реальный запрос пользователя. Ты должен ответить только на реальный запрос пользователя.
92
+ ####
93
+ """
components/llm/utils.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from components.llm.common import ChatRequest, Message
2
+ from typing import List, Dict
3
+
4
+ def convert_to_openai_format(request: ChatRequest, system_prompt: str) -> List[Dict[str, str]]:
5
+ """
6
+ Преобразует ChatRequest и system_prompt в формат OpenAI API, включая searchResults.
7
+
8
+ Args:
9
+ request (ChatRequest): Запрос с историей чата.
10
+ system_prompt (str): Системный промпт.
11
+
12
+ Returns:
13
+ List[Dict[str, str]]: История в формате OpenAI [{'role': str, 'content': str}, ...].
14
+ """
15
+ # Добавляем системный промпт как первое сообщение
16
+ openai_history = [{"role": "system", "content": system_prompt}]
17
+
18
+ # Преобразуем историю из ChatRequest
19
+ for message in request.history:
20
+ content = message.content
21
+ if message.searchResults:
22
+ search_results = "\n".join(message.searchResults)
23
+ content += f"\n<search-results>\n{search_results}\n</search-results>"
24
+
25
+ openai_history.append({
26
+ "role": message.role,
27
+ "content": content
28
+ })
29
+
30
+ return openai_history
31
+
32
+
33
+ def append_llm_response_to_history(history: ChatRequest, llm_response: str) -> ChatRequest:
34
+ """
35
+ Добавляет ответ LLM в историю чата.
36
+
37
+ Args:
38
+ history (ChatRequest): Текущая история чата.
39
+ llm_response (str): Текст ответа от LLM.
40
+
41
+ Returns:
42
+ ChatRequest: Обновленная история с добавленным ответом.
43
+ """
44
+ # Создаем новое сообщение от assistant
45
+ assistant_message = Message(
46
+ role="assistant",
47
+ content=llm_response,
48
+ searchResults=[] # Пустой список, если searchResults не предоставлены
49
+ )
50
+
51
+ # Добавляем сообщение в историю
52
+ updated_history = history.history + [assistant_message]
53
+
54
+ # Возвращаем новый объект ChatRequest с обновленной историей
55
+ return ChatRequest(history=updated_history)
components/llm/vllm_api-sync.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import requests
4
+ from typing import Optional, List, Any
5
+ from pydantic import BaseModel, Field
6
+
7
+ class LlmPredictParams(BaseModel):
8
+ """
9
+ Параметры для предсказания LLM.
10
+ """
11
+ system_prompt: Optional[str] = Field(None, description="Системный промпт.")
12
+ user_prompt: Optional[str] = Field(None, description="Шаблон промпта для передачи от роли user.")
13
+ n_predict: Optional[int] = None
14
+ temperature: Optional[float] = None
15
+ top_k: Optional[int] = None
16
+ top_p: Optional[float] = None
17
+ min_p: Optional[float] = None
18
+ seed: Optional[int] = None
19
+ repeat_penalty: Optional[float] = None
20
+ repeat_last_n: Optional[int] = None
21
+ retry_if_text_not_present: Optional[str] = None
22
+ retry_count: Optional[int] = None
23
+ presence_penalty: Optional[float] = None
24
+ frequency_penalty: Optional[float] = None
25
+ n_keep: Optional[int] = None
26
+ cache_prompt: Optional[bool] = None
27
+ stop: Optional[List[str]] = None
28
+
29
+
30
+ class LlmParams(BaseModel):
31
+ """
32
+ Основные параметры для LLM.
33
+ """
34
+ url: str
35
+ type: Optional[str] = None
36
+ default: Optional[bool] = None
37
+ template: Optional[str] = None
38
+ predict_params: Optional[LlmPredictParams] = None
39
+
40
+ class LlmApi:
41
+ """
42
+ Класс для работы с API vllm.
43
+ """
44
+
45
+ params: LlmParams = None
46
+
47
+ def __init__(self, params: LlmParams):
48
+ self.params = params
49
+
50
+
51
+ def get_models(self) -> list[str]:
52
+ """
53
+ Выполняет GET-запрос к API для получения списка доступных моделей.
54
+
55
+ Возвращает:
56
+ list[str]: Список идентификаторов моделей.
57
+ Если произошла ошибка или данные недоступны, возвращается пустой список.
58
+
59
+ Исключения:
60
+ Все ошибки HTTP-запросов логируются в консоль, но не выбрасываются дальше.
61
+ """
62
+
63
+ try:
64
+ response = requests.get(f"{self.params.url}/v1/models", headers={"Content-Type": "application/json"})
65
+
66
+ if response.status_code == 200:
67
+ json_data = response.json()
68
+ result = [item['id'] for item in json_data.get('data', [])]
69
+ return result
70
+
71
+ except requests.RequestException as error:
72
+ print('OpenAiService.getModels error:')
73
+ print(error)
74
+
75
+ return []
76
+
77
+ def create_messages(self, prompt: str) -> list[dict]:
78
+ """
79
+ Создает сообщения для LLM на основе переданного промпта и системного промпта (если он задан).
80
+
81
+ Args:
82
+ prompt (str): Пользовательский промпт.
83
+
84
+ Returns:
85
+ list[dict]: Список сообщений с ролями и содержимым.
86
+ """
87
+ actual_prompt = self.apply_llm_template_to_prompt(prompt)
88
+ messages = []
89
+
90
+ if self.params.predict_params and self.params.predict_params.system_prompt:
91
+ messages.append({"role": "system", "content": self.params.predict_params.system_prompt})
92
+
93
+ messages.append({"role": "user", "content": actual_prompt})
94
+ return messages
95
+
96
+ def apply_llm_template_to_prompt(self, prompt: str) -> str:
97
+ """
98
+ Применяет шаблон LLM к переданному промпту, если он задан.
99
+
100
+ Args:
101
+ prompt (str): Пользовательский промпт.
102
+
103
+ Returns:
104
+ str: Промпт с примененным шаблоном (или оригинальный, если шаблон отсутствует).
105
+ """
106
+ actual_prompt = prompt
107
+ if self.params.template is not None:
108
+ actual_prompt = self.params.template.replace("{{PROMPT}}", actual_prompt)
109
+ return actual_prompt
110
+
111
+ def tokenize(self, prompt: str) -> Optional[dict]:
112
+ """
113
+ Выполняет токенизацию переданного промпта.
114
+
115
+ Args:
116
+ prompt (str): Промпт для токенизации.
117
+
118
+ Returns:
119
+ Optional[dict]: Словарь с токенами и максимальной длиной модели, если запрос успешен.
120
+ Если запрос неуспешен, возвращает None.
121
+ """
122
+ model = self.get_models()[0] if self.get_models() else None
123
+ if not model:
124
+ print("No models available for tokenization.")
125
+ return None
126
+
127
+ actual_prompt = self.apply_llm_template_to_prompt(prompt)
128
+ request_data = {
129
+ "model": model,
130
+ "prompt": actual_prompt,
131
+ "add_special_tokens": False,
132
+ }
133
+
134
+ try:
135
+ response = requests.post(
136
+ f"{self.params.url}/tokenize",
137
+ json=request_data,
138
+ headers={"Content-Type": "application/json"},
139
+ )
140
+
141
+ if response.ok:
142
+ data = response.json()
143
+ if "tokens" in data:
144
+ return {"tokens": data["tokens"], "maxLength": data.get("max_model_len")}
145
+ elif response.status_code == 404:
146
+ print("Tokenization endpoint not found (404).")
147
+ else:
148
+ print(f"Failed to tokenize: {response.status_code}")
149
+ except requests.RequestException as e:
150
+ print(f"Request failed: {e}")
151
+
152
+ return None
153
+
154
+ def detokenize(self, tokens: List[int]) -> Optional[str]:
155
+ """
156
+ Выполняет детокенизацию переданных токенов.
157
+
158
+ Args:
159
+ tokens (List[int]): Список токенов для детокенизации.
160
+
161
+ Returns:
162
+ Optional[str]: Строка, полученная в результате детокенизации, если запрос успешен.
163
+ Если запрос неуспешен, возвращает None.
164
+ """
165
+ model = self.get_models()[0] if self.get_models() else None
166
+ if not model:
167
+ print("No models available for detokenization.")
168
+ return None
169
+
170
+ request_data = {"model": model, "tokens": tokens or []}
171
+
172
+ try:
173
+ response = requests.post(
174
+ f"{self.params.url}/detokenize",
175
+ json=request_data,
176
+ headers={"Content-Type": "application/json"},
177
+ )
178
+
179
+ if response.ok:
180
+ data = response.json()
181
+ if "prompt" in data:
182
+ return data["prompt"].strip()
183
+ elif response.status_code == 404:
184
+ print("Detokenization endpoint not found (404).")
185
+ else:
186
+ print(f"Failed to detokenize: {response.status_code}")
187
+ except requests.RequestException as e:
188
+ print(f"Request failed: {e}")
189
+
190
+ return None
191
+
192
+ def create_request(self, prompt: str) -> dict:
193
+ """
194
+ Создает запрос для предсказания на основе параметров LLM.
195
+
196
+ Args:
197
+ prompt (str): Промпт для запроса.
198
+
199
+ Returns:
200
+ dict: Словарь с параметрами для выполнения запроса.
201
+ """
202
+ llm_params = self.params
203
+ models = self.get_models()
204
+ if not models:
205
+ raise ValueError("No models available to create a request.")
206
+ model = models[0]
207
+
208
+ request = {
209
+ "stream": True,
210
+ "model": model,
211
+ }
212
+
213
+ predict_params = llm_params.predict_params
214
+
215
+ if predict_params:
216
+ if predict_params.stop:
217
+ # Фильтруем пустые строки в stop
218
+ non_empty_stop = list(filter(lambda o: o != "", predict_params.stop))
219
+ if non_empty_stop:
220
+ request["stop"] = non_empty_stop
221
+
222
+ if predict_params.n_predict is not None:
223
+ request["max_tokens"] = int(predict_params.n_predict or 0)
224
+
225
+ request["temperature"] = float(predict_params.temperature or 0)
226
+
227
+ if predict_params.top_k is not None:
228
+ request["top_k"] = int(predict_params.top_k)
229
+
230
+ if predict_params.top_p is not None:
231
+ request["top_p"] = float(predict_params.top_p)
232
+
233
+ if predict_params.min_p is not None:
234
+ request["min_p"] = float(predict_params.min_p)
235
+
236
+ if predict_params.seed is not None:
237
+ request["seed"] = int(predict_params.seed)
238
+
239
+ if predict_params.n_keep is not None:
240
+ request["n_keep"] = int(predict_params.n_keep)
241
+
242
+ if predict_params.cache_prompt is not None:
243
+ request["cache_prompt"] = bool(predict_params.cache_prompt)
244
+
245
+ if predict_params.repeat_penalty is not None:
246
+ request["repetition_penalty"] = float(predict_params.repeat_penalty)
247
+
248
+ if predict_params.repeat_last_n is not None:
249
+ request["repeat_last_n"] = int(predict_params.repeat_last_n)
250
+
251
+ if predict_params.presence_penalty is not None:
252
+ request["presence_penalty"] = float(predict_params.presence_penalty)
253
+
254
+ if predict_params.frequency_penalty is not None:
255
+ request["frequency_penalty"] = float(predict_params.frequency_penalty)
256
+
257
+ # Генерируем сообщения
258
+ request["messages"] = self.create_messages(prompt)
259
+
260
+ return request
261
+
262
+
263
+ def trim_sources(self, sources: str, user_request: str, system_prompt: str = None) -> dict:
264
+ """
265
+ Обрезает текст источников, чтобы уложиться в допустимое количество токенов.
266
+
267
+ Args:
268
+ sources (str): Текст источников.
269
+ user_request (str): Запрос пользователя с примененным шаблоном без текста источников.
270
+ system_prompt (str): Системный промпт, если нужен.
271
+
272
+ Returns:
273
+ dict: Словарь с результатом, количеством токенов до и после обрезки.
274
+ """
275
+ # Токенизация текста источников
276
+ sources_tokens_data = self.tokenize(sources)
277
+ if sources_tokens_data is None:
278
+ raise ValueError("Failed to tokenize sources.")
279
+ max_token_count = sources_tokens_data.get("maxLength", 0)
280
+
281
+ # Токены системного промпта
282
+ system_prompt_token_count = 0
283
+
284
+ if system_prompt is not None:
285
+ system_prompt_tokens = self.tokenize(system_prompt)
286
+ system_prompt_token_count = len(system_prompt_tokens["tokens"]) if system_prompt_tokens else 0
287
+
288
+ # Оригинальное количество токенов
289
+ original_token_count = len(sources_tokens_data["tokens"])
290
+
291
+ # Токенизация пользовательского промпта
292
+ aux_prompt = self.apply_llm_template_to_prompt(user_request)
293
+ aux_tokens_data = self.tokenize(aux_prompt)
294
+
295
+ aux_token_count = len(aux_tokens_data["tokens"]) if aux_tokens_data else 0
296
+
297
+ # Максимально допустимое количество токенов для источников
298
+ max_length = (
299
+ max_token_count
300
+ - (self.params.predict_params.n_predict or 0)
301
+ - aux_token_count
302
+ - system_prompt_token_count
303
+ )
304
+ max_length = max(max_length, 0)
305
+
306
+ # Обрезка токенов источников
307
+ if "tokens" in sources_tokens_data:
308
+ sources_tokens_data["tokens"] = sources_tokens_data["tokens"][:max_length]
309
+ detokenized_prompt = self.detokenize(sources_tokens_data["tokens"])
310
+ if detokenized_prompt is not None:
311
+ sources = detokenized_prompt
312
+ else:
313
+ sources = sources[:max_length]
314
+ else:
315
+ sources = sources[:max_length]
316
+
317
+ # Возврат результата
318
+ return {
319
+ "result": sources,
320
+ "originalTokenCount": original_token_count,
321
+ "slicedTokenCount": len(sources_tokens_data["tokens"]),
322
+ }
323
+
324
+ def predict(self, prompt: str) -> str:
325
+ """
326
+ Выполняет SSE-запрос к API и возвращает собранный результат как текст.
327
+
328
+ Args:
329
+ prompt (str): Входной текст для предсказания.
330
+
331
+ Returns:
332
+ str: Сгенерированный текст.
333
+
334
+ Raises:
335
+ Exception: Если запрос завершился ошибкой.
336
+ """
337
+
338
+ # Создание запроса
339
+ request = self.create_request(prompt)
340
+
341
+ print(f"Predict request. Url: {self.params.url}")
342
+
343
+ response = requests.post(
344
+ f"{self.params.url}/v1/chat/completions",
345
+ headers={"Content-Type": "application/json"},
346
+ json=request,
347
+ stream=True # Для обработки SSE
348
+ )
349
+
350
+ if not response.ok:
351
+ raise Exception(f"Failed to generate text: {response.text}")
352
+
353
+ # Обработка SSE-ответа
354
+ generated_text = ""
355
+ for line in response.iter_lines(decode_unicode=True):
356
+ if line.startswith("data: "):
357
+ try:
358
+ data = json.loads(line[len("data: "):].strip())
359
+
360
+ # Проверка завершения генерации
361
+ if data == "[DONE]":
362
+ break
363
+
364
+ # Получение текста из ответа
365
+ if "choices" in data and data["choices"]:
366
+ token_value = data["choices"][0].get("delta", {}).get("content", "")
367
+ generated_text += token_value.replace("</s>", "")
368
+
369
+ except json.JSONDecodeError:
370
+ continue # Игнорирование строк, которые не удалось декодировать
371
+
372
+ return generated_text
373
+
374
+
375
+
components/llm/vllm_api.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Optional, List
3
+
4
+ import httpx
5
+ from llm.common import LlmParams, LlmApi
6
+
7
+
8
+ class LlmApi(LlmApi):
9
+ """
10
+ Класс для работы с API vllm.
11
+ """
12
+
13
+ def __init__(self, params: LlmParams):
14
+ super().__init__()
15
+ super().set_params(params)
16
+
17
+ async def get_models(self) -> List[str]:
18
+ """
19
+ Выполняет GET-запрос к API для получения списка доступных моделей.
20
+
21
+ Возвращает:
22
+ list[str]: Список идентификаторов моделей.
23
+ Если произошла ошибка или данные недоступны, возвращается пустой список.
24
+
25
+ Исключения:
26
+ Все ошибки HTTP-запросов логируются в консоль, но не выбрасываются дальше.
27
+ """
28
+ try:
29
+ async with httpx.AsyncClient() as client:
30
+ response = await client.get(f"{self.params.url}/v1/models", headers=super().create_headers())
31
+ if response.status_code == 200:
32
+ json_data = response.json()
33
+ return [item['id'] for item in json_data.get('data', [])]
34
+ except httpx.RequestError as error:
35
+ print('Error fetching models:', error)
36
+ return []
37
+
38
+ async def get_model(self) -> str:
39
+ model = None
40
+ if self.params.model is not None:
41
+ model = self.params.model
42
+ else:
43
+ models = await self.get_models()
44
+ model = models[0] if models else None
45
+
46
+ if model is None:
47
+ raise Exception("No model name provided and no models available.")
48
+
49
+ return model
50
+
51
+ def create_messages(self, prompt: str) -> List[dict]:
52
+ """
53
+ Создает сообщения для LLM на основе переданного промпта и системного промпта (если он задан).
54
+
55
+ Args:
56
+ prompt (str): Пользовательский промпт.
57
+
58
+ Returns:
59
+ list[dict]: Список сообщений с ролями и содержимым.
60
+ """
61
+ actual_prompt = self.apply_llm_template_to_prompt(prompt)
62
+ messages = []
63
+ if self.params.predict_params and self.params.predict_params.system_prompt:
64
+ messages.append({"role": "system", "content": self.params.predict_params.system_prompt})
65
+ messages.append({"role": "user", "content": actual_prompt})
66
+ return messages
67
+
68
+ def apply_llm_template_to_prompt(self, prompt: str) -> str:
69
+ """
70
+ Применяет шаблон LLM к переданному промпту, если он задан.
71
+
72
+ Args:
73
+ prompt (str): Пользовательский промпт.
74
+
75
+ Returns:
76
+ str: Промпт с примененным шаблоном (или оригинальный, если шаблон отсутствует).
77
+ """
78
+ actual_prompt = prompt
79
+ if self.params.template is not None:
80
+ actual_prompt = self.params.template.replace("{{PROMPT}}", actual_prompt)
81
+ return actual_prompt
82
+
83
+ async def tokenize(self, prompt: str) -> Optional[dict]:
84
+ """
85
+ Выполняет токенизацию переданного промпта.
86
+
87
+ Args:
88
+ prompt (str): Промпт для токенизации.
89
+
90
+ Returns:
91
+ Optional[dict]: Словарь с токенами и максимальной длиной модели, если запрос успешен.
92
+ Если запрос неуспешен, возвращает None.
93
+ """
94
+
95
+ actual_prompt = self.apply_llm_template_to_prompt(prompt)
96
+ request_data = {
97
+ "model": self.get_model(),
98
+ "prompt": actual_prompt,
99
+ "add_special_tokens": False,
100
+ }
101
+
102
+ try:
103
+ async with httpx.AsyncClient() as client:
104
+ response = await client.post(
105
+ f"{self.params.url}/tokenize",
106
+ json=request_data,
107
+ headers=super().create_headers(),
108
+ )
109
+ if response.status_code == 200:
110
+ data = response.json()
111
+ if "tokens" in data:
112
+ return {"tokens": data["tokens"], "max_length": data.get("max_model_len")}
113
+ elif response.status_code == 404:
114
+ print("Tokenization endpoint not found (404).")
115
+ else:
116
+ print(f"Failed to tokenize: {response.status_code}")
117
+ except httpx.RequestError as e:
118
+ print(f"Request failed: {e}")
119
+
120
+ return None
121
+
122
+ async def detokenize(self, tokens: List[int]) -> Optional[str]:
123
+ """
124
+ Выполняет д��токенизацию переданных токенов.
125
+
126
+ Args:
127
+ tokens (List[int]): Список токенов для детокенизации.
128
+
129
+ Returns:
130
+ Optional[str]: Строка, полученная в результате детокенизации, если запрос успешен.
131
+ Если запрос неуспешен, возвращает None.
132
+ """
133
+
134
+ request_data = {"model": self.get_model(), "tokens": tokens or []}
135
+
136
+ try:
137
+ async with httpx.AsyncClient() as client:
138
+ response = await client.post(
139
+ f"{self.params.url}/detokenize",
140
+ json=request_data,
141
+ headers=super().create_headers(),
142
+ )
143
+ if response.status_code == 200:
144
+ data = response.json()
145
+ if "prompt" in data:
146
+ return data["prompt"].strip()
147
+ elif response.status_code == 404:
148
+ print("Detokenization endpoint not found (404).")
149
+ else:
150
+ print(f"Failed to detokenize: {response.status_code}")
151
+ except httpx.RequestError as e:
152
+ print(f"Request failed: {e}")
153
+
154
+ return None
155
+
156
+ async def create_request(self, prompt: str) -> dict:
157
+ """
158
+ Создает запрос для предсказания на основе параметров LLM.
159
+
160
+ Args:
161
+ prompt (str): Промпт для запроса.
162
+
163
+ Returns:
164
+ dict: Словарь с параметрами для выполнения запроса.
165
+ """
166
+ model = self.get_model()
167
+
168
+ request = {
169
+ "stream": True,
170
+ "model": model,
171
+ }
172
+
173
+ predict_params = self.params.predict_params
174
+ if predict_params:
175
+ if predict_params.stop:
176
+ non_empty_stop = list(filter(lambda o: o != "", predict_params.stop))
177
+ if non_empty_stop:
178
+ request["stop"] = non_empty_stop
179
+
180
+ if predict_params.n_predict is not None:
181
+ request["max_tokens"] = int(predict_params.n_predict or 0)
182
+
183
+ request["temperature"] = float(predict_params.temperature or 0)
184
+ if predict_params.top_k is not None:
185
+ request["top_k"] = int(predict_params.top_k)
186
+
187
+ if predict_params.top_p is not None:
188
+ request["top_p"] = float(predict_params.top_p)
189
+
190
+ if predict_params.min_p is not None:
191
+ request["min_p"] = float(predict_params.min_p)
192
+
193
+ if predict_params.seed is not None:
194
+ request["seed"] = int(predict_params.seed)
195
+
196
+ if predict_params.n_keep is not None:
197
+ request["n_keep"] = int(predict_params.n_keep)
198
+
199
+ if predict_params.cache_prompt is not None:
200
+ request["cache_prompt"] = bool(predict_params.cache_prompt)
201
+
202
+ if predict_params.repeat_penalty is not None:
203
+ request["repetition_penalty"] = float(predict_params.repeat_penalty)
204
+
205
+ if predict_params.repeat_last_n is not None:
206
+ request["repeat_last_n"] = int(predict_params.repeat_last_n)
207
+
208
+ if predict_params.presence_penalty is not None:
209
+ request["presence_penalty"] = float(predict_params.presence_penalty)
210
+
211
+ if predict_params.frequency_penalty is not None:
212
+ request["frequency_penalty"] = float(predict_params.frequency_penalty)
213
+
214
+ request["messages"] = self.create_messages(prompt)
215
+ return request
216
+
217
+ async def trim_sources(self, sources: str, user_request: str, system_prompt: str = None) -> dict:
218
+ """
219
+ Обрезает текст источников, чтобы уложиться в допустимое количество токенов.
220
+
221
+ Args:
222
+ sources (str): Текст источников.
223
+ user_request (str): Запрос пользователя с примененным шаблоном без текста источников.
224
+ system_prompt (str): Системный промпт, если нужен.
225
+
226
+ Returns:
227
+ dict: Словарь с результатом, количеством токенов до и после обрезки.
228
+ """
229
+ # Токенизация текста источников
230
+ sources_tokens_data = await self.tokenize(sources)
231
+ if sources_tokens_data is None:
232
+ raise ValueError("Failed to tokenize sources.")
233
+ max_token_count = sources_tokens_data.get("maxLength", 0)
234
+
235
+ # Токены системного промпта
236
+ system_prompt_token_count = 0
237
+
238
+ if system_prompt is not None:
239
+ system_prompt_tokens = await self.tokenize(system_prompt)
240
+ system_prompt_token_count = len(system_prompt_tokens["tokens"]) if system_prompt_tokens else 0
241
+
242
+ # Оригинальное количество токенов
243
+ original_token_count = len(sources_tokens_data["tokens"])
244
+
245
+ # Токенизация пользовательского промпта
246
+ aux_prompt = self.apply_llm_template_to_prompt(user_request)
247
+ aux_tokens_data = await self.tokenize(aux_prompt)
248
+
249
+ aux_token_count = len(aux_tokens_data["tokens"]) if aux_tokens_data else 0
250
+
251
+ # Максимально допустимое количество токенов для источников
252
+ max_length = (
253
+ max_token_count
254
+ - (self.params.predict_params.n_predict or 0)
255
+ - aux_token_count
256
+ - system_prompt_token_count
257
+ )
258
+ max_length = max(max_length, 0)
259
+
260
+ # Обрезка токенов источников
261
+ if "tokens" in sources_tokens_data:
262
+ sources_tokens_data["tokens"] = sources_tokens_data["tokens"][:max_length]
263
+ detokenized_prompt = await self.detokenize(sources_tokens_data["tokens"])
264
+ if detokenized_prompt is not None:
265
+ sources = detokenized_prompt
266
+ else:
267
+ sources = sources[:max_length]
268
+ else:
269
+ sources = sources[:max_length]
270
+
271
+ # Возврат результата
272
+ return {
273
+ "result": sources,
274
+ "originalTokenCount": original_token_count,
275
+ "slicedTokenCount": len(sources_tokens_data["tokens"]),
276
+ }
277
+
278
+ async def predict(self, prompt: str) -> str:
279
+ """
280
+ Выполняет запрос к API с поддержкой потокового вывода (SSE) и возвращает результат.
281
+
282
+ Args:
283
+ prompt (str): Входной текст для предсказания.
284
+
285
+ Returns:
286
+ str: Сгенерированный текст.
287
+ """
288
+ async with httpx.AsyncClient() as client:
289
+ # Формируем тело запроса
290
+ request = await self.create_request(prompt)
291
+
292
+ # Начинаем потоковый запрос
293
+ async with client.stream("POST", f"{self.params.url}/v1/chat/completions", json=request) as response:
294
+ if response.status_code != 200:
295
+ # Если ошибка, читаем ответ для получения подробностей
296
+ error_content = await response.aread()
297
+ raise Exception(f"API error: {error_content.decode('utf-8')}")
298
+
299
+ # Для хранения результата
300
+ generated_text = ""
301
+
302
+ # Асинхронное чтение построчно
303
+ async for line in response.aiter_lines():
304
+ if line.startswith("data: "): # SSE-сообщения начинаются с "data: "
305
+ try:
306
+ # Парсим JSON из строки
307
+ data = json.loads(line[len("data: "):].strip())
308
+ if data == "[DONE]": # Конец потока
309
+ break
310
+ if "choices" in data and data["choices"]:
311
+ # Получаем текст из текущего токена
312
+ token_value = data["choices"][0].get("delta", {}).get("content", "")
313
+ generated_text += token_value
314
+ except json.JSONDecodeError:
315
+ continue # Игнорируем строки, которые не удается декодировать
316
+
317
+ return generated_text.strip()
components/nmd/aggregate_answers.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Optional, Tuple
2
+ import requests
3
+ from logging import Logger
4
+
5
+ from common.configuration import SemanticChunk
6
+ from common.configuration import SegmentationSearch
7
+ from common.configuration import SummaryChunks
8
+ from common.configuration import FilterChunks
9
+ from common.configuration import RocksNNSearch
10
+ from common.configuration import PeopleChunks
11
+ from common.configuration import SearchGroupComposition
12
+
13
+
14
+ def aggregate_answers(vector_answer: Optional[Dict] = None,
15
+ people_answer: Optional[List] = None,
16
+ chunks_answer: Optional[List] = None,
17
+ groups_answer: Optional[List] = None,
18
+ rocks_nn_answer: Optional[List] = None,
19
+ segmentation_answer: Optional[List] = None) -> Dict:
20
+ """
21
+
22
+ Args:
23
+ vector_answer:
24
+ people_answer:
25
+ chunks_answer:
26
+ groups_answer:
27
+ rocks_nn_answer:
28
+ segmentation_answer:
29
+
30
+ Returns:
31
+
32
+ """
33
+ answer = {}
34
+ if vector_answer is not None or chunks_answer is not None:
35
+ answer['doc_chunks'] = combine_answer([vector_answer, chunks_answer])
36
+ if people_answer is not None:
37
+ answer['people_search'] = [PeopleChunks(**answer_dict['_source']) for answer_dict in people_answer]
38
+ if groups_answer is not None:
39
+ answer['groups_search'] = SearchGroupComposition(**groups_answer[0]['_source'])
40
+ if rocks_nn_answer is not None:
41
+ answer['rocks_nn_search'] = RocksNNSearch(division=rocks_nn_answer[0]['_source']['division_name'],
42
+ company_name=rocks_nn_answer[0]['_source']['company_name'])
43
+ if segmentation_answer is not None:
44
+ answer['segmentation_search'] = SegmentationSearch(**segmentation_answer[0]['_source'])
45
+
46
+ return answer
47
+
48
+
49
+ def combine_answer(answers):
50
+ """
51
+
52
+ Args:
53
+ answers:
54
+
55
+ Returns:
56
+
57
+ """
58
+ answer_combined = []
59
+ answer_file_names = []
60
+ indexes = []
61
+ for answer in answers:
62
+ if answer is not None:
63
+ for key in answer:
64
+ if answer[key]["doc_name"] in answer_file_names:
65
+ if answer[key]['start_index_paragraph'] not in indexes:
66
+ obj_index = answer_file_names.index(answer[key]["doc_name"])
67
+ answer_combined[obj_index].chunks.append(SemanticChunk(**answer[key]))
68
+ else:
69
+ answer_combined.append(FilterChunks(
70
+ id=str(answer[key]['id']),
71
+ filename=answer[key]["doc_name"],
72
+ title=answer[key]["title"],
73
+ chunks=[SemanticChunk(**answer[key])]))
74
+ answer_file_names.append(answer[key]["doc_name"])
75
+ indexes.append(answer[key]['start_index_paragraph'])
76
+ return answer_combined
77
+
78
+
79
+ def preprocessed_chunks(answer_chunks: SummaryChunks, llm_host_tokens: str, logger: Logger) -> str:
80
+ output_text = ''
81
+ count = 0
82
+ count_tokens = 0
83
+ if answer_chunks.doc_chunks is not None:
84
+ for doc in answer_chunks.doc_chunks:
85
+ output_text += f'Документ: [{count + 1}]\n'
86
+ if doc.title != 'unknown':
87
+ output_text += f'Название документа: {doc.title}\n'
88
+ else:
89
+ output_text += f'Название документа: {doc.filename}\n'
90
+ for chunk in doc.chunks:
91
+ if len(chunk.other_info):
92
+ output_text += '...\n'
93
+ for i in chunk.other_info:
94
+ output_text += f'{i}'.replace('', '-')
95
+ output_text += '...\n'
96
+ else:
97
+ output_text += '...\n'
98
+ output_text += f'{chunk.text_answer}'
99
+ output_text += '...\n'
100
+ count_tokens = len(output_text) * 2
101
+ #TODO: в deepinfra нет такой возможности. Нужно прокинуть токенизатор
102
+ #len(requests.post(url=f'{llm_host_tokens}', json={"content": output_text}).json()['tokens'])
103
+ if count_tokens > 20000:
104
+ logger.info('Количество токенов превысило значение 20k! Оставшиеся чанки отброшены!')
105
+ break
106
+
107
+ if count_tokens > 20000:
108
+ output_text += '\n\\\n\n'
109
+ count += 1
110
+ break
111
+
112
+ output_text += '\n\\\n\n'
113
+ count += 1
114
+
115
+ if answer_chunks.people_search is not None:
116
+ for doc in answer_chunks.people_search:
117
+ output_text += f'Документ: [{count + 1}]\n'
118
+ output_text += f'Название документа: Информация о сотруднике {doc.person_name}\n'
119
+ output_text += f'Информация о сотруднике {doc.person_name}\n'
120
+ if doc.organizatinal_structure is not None:
121
+ for organizatinal_structure in doc.organizatinal_structure:
122
+ output_text += '[\n'
123
+ if organizatinal_structure.position != 'undefined':
124
+ output_text += f'Должность: {organizatinal_structure.position}'
125
+ if organizatinal_structure.leads is not None:
126
+ output_text += f'\nРуководит следующими сотрудниками:\n'
127
+ for lead in organizatinal_structure.leads:
128
+ if lead.person != "undefined":
129
+ output_text += f'{lead.person}\n'
130
+ if organizatinal_structure.subordinates is not None:
131
+ if organizatinal_structure.subordinates.person_name != "undefined":
132
+ output_text += f'Руководителем {doc.person_name} является {organizatinal_structure.subordinates.person_name}'
133
+ output_text += '\n]\n'
134
+
135
+ if doc.business_processes is not None:
136
+ if len(doc.business_processes) >= 2:
137
+ output_text += f'Отвечает за Бизнес процессы:\n'
138
+ else:
139
+ output_text += f'Отвечает за Бизнес процесс: '
140
+ for process in doc.business_processes:
141
+ output_text += f'{process.processes_name}\n'
142
+ if doc.business_curator is not None:
143
+ output_text += 'Является Бизнес-куратором (РОКС НН):\n'
144
+ for curator in doc.business_curator:
145
+ output_text += f'{curator.company_name}\n'
146
+ if doc.groups is not None:
147
+ output_text += '\nВходит в состав групп, комитетов, координационных советов (КО):\n'
148
+ for group in doc.groups:
149
+ if 'Члены' in group.position_in_group:
150
+ output_text += f'{group.group_name}. Должность внутри группы: {group.position_in_group.replace("Члены", "Член")}\n'
151
+ else:
152
+ output_text += f'{group.group_name}. Должность внутри группы: {group.position_in_group}\n'
153
+ output_text += f'\n\\\n\n'
154
+ count += 1
155
+
156
+ if answer_chunks.groups_search is not None:
157
+ output_text += f'Документ: [{count + 1}]\n'
158
+ output_text += f'Название документа: Информация о группе\n'
159
+ output_text += f'Название группы: {answer_chunks.groups_search.group_name}\n'
160
+ if len(answer_chunks.groups_search.group_composition) > 1:
161
+ output_text += f'\t ФИО \t\t\t| Должность внутри группы\n'
162
+ for person_data in answer_chunks.groups_search.group_composition:
163
+ if 'Члены' in person_data.position_in_group:
164
+ output_text += f'{person_data.person_name:<{20}}| {person_data.position_in_group.replace("Члены", "Член")}\n'
165
+ else:
166
+ output_text += f'{person_data.person_name:<{20}}| {person_data.position_in_group}\n'
167
+ output_text += f'\n\\\n\n'
168
+ count += 1
169
+
170
+ if answer_chunks.rocks_nn_search is not None:
171
+ output_text += f'Документ: [{count + 1}]\n'
172
+ output_text += f'Название документа: Информация о {answer_chunks.rocks_nn_search.division}\n'
173
+ output_text += f'Название документа: В РОКС НН {answer_chunks.rocks_nn_search.division} входят:\n'
174
+ for company_name in answer_chunks.rocks_nn_search.company_name:
175
+ output_text += f'{company_name}\n'
176
+ output_text += f'\n\\\n\n'
177
+ count += 1
178
+
179
+ if answer_chunks.segmentation_search is not None:
180
+ output_text += f'Документ: [{count + 1}]\n'
181
+ output_text += f'Название документа: {answer_chunks.segmentation_search.segmentation_model}\n'
182
+ output_text += f'Название документа: В {answer_chunks.segmentation_search.segmentation_model} входят:\n'
183
+ for company_name in answer_chunks.segmentation_search.company_name:
184
+ output_text += f'{company_name}\n'
185
+ output_text += f'\n\\\n\n'
186
+ count += 1
187
+
188
+ output_text = output_text.replace('\uf02d', '-').replace('', '-')
189
+ return output_text
components/nmd/faiss_vector_search.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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__)
13
+
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
21
+ self.path_to_metadata = config.faiss.path_to_metadata
22
+ if self.config.ranker.use_ranging:
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]
components/nmd/llm_chunk_search.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from logging import Logger
4
+ from typing import List, Union
5
+
6
+ from openai import OpenAI
7
+
8
+ from common.configuration import FilterChunks, LLMConfiguration, SummaryChunks
9
+ from components.nmd.aggregate_answers import preprocessed_chunks
10
+
11
+
12
+ class LLMChunkSearch:
13
+
14
+ def __init__(self, config: LLMConfiguration, prompt: str, logger: Logger):
15
+ self.config = config
16
+ self.logger = logger
17
+ self.prompt = prompt
18
+ self.pattern = r'\d+'
19
+ self.pattern_list = [
20
+ r'\[\d+\]',
21
+ r'Ответ: [1-9]',
22
+ r'Ответ [1-9]',
23
+ r'Ответ[1-9]',
24
+ r'Ответ:[1-9]',
25
+ r'Ответ: \[\d+\]',
26
+ ]
27
+
28
+ # Initialize OpenAI client
29
+ if self.config.base_url is not None:
30
+ self.client = OpenAI(
31
+ base_url=self.config.base_url,
32
+ api_key=os.getenv(self.config.api_key_env)
33
+ )
34
+ else:
35
+ self.client = None
36
+
37
+ def llm_chunk_search(self, query: str, answer_chunks: SummaryChunks, prompt: str):
38
+ """
39
+ Args:
40
+ query: User query
41
+ answer_chunks: Retrieved chunks to process
42
+ prompt: System prompt template
43
+
44
+ Returns:
45
+ Tuple containing processed chunks, LLM response, prompt used, and token count
46
+ """
47
+ text_chunks = preprocessed_chunks(
48
+ answer_chunks, self.config.base_url, self.logger
49
+ )
50
+ self.logger.info('Searching LLM Chunks')
51
+
52
+ if self.client is None:
53
+ return (
54
+ text_chunks,
55
+ self.__postprocessing_answer_llm(answer_chunks),
56
+ prompt,
57
+ 0
58
+ )
59
+
60
+ llm_prompt = prompt.format(query=query, answer=text_chunks)
61
+
62
+ for i in range(5):
63
+ try:
64
+ response = self.client.chat.completions.create(
65
+ model=self.config.model,
66
+ messages=[
67
+ {"role": "system", "content": prompt},
68
+ {"role": "user", "content": query}
69
+ ],
70
+ temperature=self.config.temperature,
71
+ top_p=self.config.top_p,
72
+ frequency_penalty=self.config.frequency_penalty,
73
+ presence_penalty=self.config.presence_penalty,
74
+ seed=self.config.seed
75
+ )
76
+
77
+ answer_llm = response.choices[0].message.content
78
+ count_tokens = response.usage.total_tokens
79
+
80
+ self.logger.info(f'Answer LLM {answer_llm}')
81
+
82
+ # Process the response
83
+ if re.search('%%', answer_llm):
84
+ index = re.search('%%', answer_llm).span()[1]
85
+ answer_llm = answer_llm[index:]
86
+ if re.search('Конец ответа', answer_llm):
87
+ index = re.search('Конец ответа', answer_llm).span()[1]
88
+ answer_llm = answer_llm[:index]
89
+
90
+ return text_chunks, answer_llm, llm_prompt, count_tokens
91
+
92
+ except Exception as e:
93
+ self.logger.error(f"Attempt {i+1} failed: {str(e)}")
94
+ if i == 4:
95
+ self.logger.error("All attempts failed")
96
+ return (
97
+ text_chunks,
98
+ self.__postprocessing_answer_llm(answer_chunks),
99
+ llm_prompt,
100
+ 0
101
+ )
102
+
103
+ @staticmethod
104
+ def __postprocessing_answer_llm(answer_chunks: Union[SummaryChunks, List]) -> str:
105
+ """
106
+ Postprocess the answer chunks into a formatted string
107
+
108
+ Args:
109
+ answer_chunks: Chunks to process
110
+
111
+ Returns:
112
+ Formatted string response
113
+ """
114
+ output_text = ''
115
+ if isinstance(answer_chunks, SummaryChunks):
116
+ if len(answer_chunks.doc_chunks) == 0:
117
+ # TODO: Протестировать как работает и исправить на уведомление о БД и ли
118
+ return 'БАЗА ДАННЫХ ПУСТА'
119
+ if answer_chunks.doc_chunks is not None:
120
+ doc = answer_chunks.doc_chunks[0]
121
+ output_text += f'Документ: [1]\n'
122
+ if doc.title != 'unknown':
123
+ output_text += f'Название документа: {doc.title}\n'
124
+ else:
125
+ output_text += f'Название документа: {doc.filename}\n'
126
+ for chunk in doc.chunks:
127
+ if len(chunk.other_info):
128
+ for i in chunk.other_info:
129
+ output_text += f'{i}'
130
+ else:
131
+ output_text += f'{chunk.text_answer}'
132
+ output_text += '\n\n'
133
+ else:
134
+ doc = answer_chunks.people_search[0]
135
+ output_text += (
136
+ f'Название документа: Информация о сотруднике {doc.person_name}\n'
137
+ )
138
+ if doc.organizatinal_structure is not None:
139
+ for organizatinal_structure in doc.organizatinal_structure:
140
+ output_text += '('
141
+ if organizatinal_structure.position != 'undefined':
142
+ output_text += (
143
+ f'Должность: {organizatinal_structure.position}\n'
144
+ )
145
+ if organizatinal_structure.leads is not None:
146
+ output_text += f'Руководит следующими сотрудниками:\n'
147
+ for lead in organizatinal_structure.leads:
148
+ if lead.person != "undefined":
149
+ output_text += f'{lead.person}\n'
150
+ if (
151
+ organizatinal_structure.subordinates.person_name
152
+ != "undefined"
153
+ ):
154
+ output_text += f'Руководителем {doc.person_name} является {organizatinal_structure.subordinates.person_name}\n'
155
+ output_text += ')'
156
+
157
+ if doc.business_processes is not None:
158
+ if len(doc.business_processes) >= 2:
159
+ output_text += f'Отвечает за Бизнес процессы:\n'
160
+ else:
161
+ output_text += f'Отвечает за Бизнес процесс: '
162
+ for process in doc.business_processes:
163
+ output_text += f'{process.processes_name}\n'
164
+ if doc.business_curator is not None:
165
+ output_text += 'Является Бизнес-куратором (РОКС НН):\n'
166
+ for curator in doc.business_curator:
167
+ output_text += f'{curator.company_name}'
168
+ if doc.groups is not None:
169
+ if len(doc.groups) >= 2:
170
+ output_text += 'Входит в состав групп:\n'
171
+ else:
172
+ output_text += 'Входит в состав группы:\n'
173
+ for group in doc.groups:
174
+ if 'Члены' in group.position_in_group:
175
+ output_text += f'{group.group_name}. Должность внутри группы: {group.position_in_group.replace("Члены", "Член")}\n'
176
+ else:
177
+ output_text += f'{group.group_name}. Должность внутри группы: {group.position_in_group}\n'
178
+ output_text += f'\\\n\n'
179
+
180
+ else:
181
+ if isinstance(answer_chunks[0], FilterChunks):
182
+ doc = answer_chunks[0]
183
+ output_text += f'Документ: [1]\n'
184
+ if doc.title != 'unknown':
185
+ output_text += f'Название документа: {doc.title}\n'
186
+ for chunk in doc.chunks:
187
+ if len(chunk.other_info):
188
+ for i in chunk.other_info:
189
+ output_text += f'{i}'
190
+ else:
191
+ output_text += f'{chunk.text_answer}'
192
+ output_text += '\n\n'
193
+ else:
194
+ doc = answer_chunks[0]
195
+ output_text += f'Информация о сотруднике {doc.person_name}\n'
196
+ if doc.organizatinal_structure is not None:
197
+ for organizatinal_structure in doc.organizatinal_structure:
198
+ output_text += (
199
+ f'Должность: {organizatinal_structure.position}\n'
200
+ )
201
+ if organizatinal_structure.leads is not None:
202
+ output_text += f'Руководит следующими сотрудниками:\n'
203
+ for lead in organizatinal_structure.leads:
204
+ if lead.person != "undefined":
205
+ output_text += f'{lead.person}\n'
206
+ if (
207
+ organizatinal_structure.subordinates.person_name
208
+ != "undefined"
209
+ ):
210
+ output_text += f'Руководителем {doc.person_name} является {organizatinal_structure.subordinates.person_name}\n'
211
+
212
+ if doc.business_processes is not None:
213
+ if len(doc.business_processes) >= 2:
214
+ output_text += f'Отвечает за Бизнес процессы:\n'
215
+ else:
216
+ output_text += f'Отвечает за Бизнес процесс: '
217
+ for process in doc.business_processes:
218
+ output_text += f'{process.processes_name}\n'
219
+ if doc.business_curator is not None:
220
+ output_text += 'Является Бизнес-куратором (РОКС НН):\n'
221
+ for curator in doc.business_curator:
222
+ output_text += f'{curator.company_name}'
223
+ if doc.groups is not None:
224
+ if len(doc.groups) >= 2:
225
+ output_text += 'Входит в состав групп:\n'
226
+ else:
227
+ output_text += 'Входит в состав группы:\n'
228
+ for group in doc.groups:
229
+ if 'Члены' in group.position_in_group:
230
+ output_text += f'{group.group_name}. Должность внутри группы: {group.position_in_group.replace("Члены", "Член")}\n'
231
+ else:
232
+ output_text += f'{group.group_name}. Должность внутри группы: {group.position_in_group}\n'
233
+ output_text += f'\\\n\n'
234
+
235
+ return output_text
components/nmd/metadata_manager.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Tuple, Optional
2
+
3
+ import pandas as pd
4
+
5
+
6
+ class MetadataManager:
7
+ def __init__(self, df: pd.DataFrame, logger):
8
+ self.logger = logger
9
+ self.df = df
10
+ self.df.drop('Embedding', axis=1, inplace=True)
11
+ self.df = self.df.where(pd.notna(self.df), 'unknown')
12
+
13
+ @staticmethod
14
+ def __search_sub_level(df: pd.DataFrame, header_text: Optional[str] = None) -> List:
15
+ """
16
+
17
+ Args:
18
+ df:
19
+
20
+ Returns:
21
+
22
+ """
23
+ paragraphs = []
24
+ if header_text is None:
25
+ header_text = df.iloc[0]['Text']
26
+
27
+ for ind, (_, row) in enumerate(df.iterrows()):
28
+ text = row['Text']
29
+ if ind == 0:
30
+ text = text.replace(f'{header_text}', f'{header_text}\n')
31
+ else:
32
+ text = text.replace(f'{header_text}', '') + '\n'
33
+ paragraphs.append(text)
34
+ return paragraphs
35
+
36
+ @staticmethod
37
+ def __check_duplicates(df: pd.DataFrame, ind: int) -> pd.DataFrame:
38
+ if df.loc[ind]['Duplicate'] is not None:
39
+ return df[df['Duplicate'] == df.loc[ind]['Duplicate']]
40
+ else:
41
+ return df[df['Duplicate'].isna()]
42
+
43
+ @staticmethod
44
+ def __check_appendix_duplicates(df: pd.DataFrame, ind: int) -> pd.DataFrame:
45
+ if df.loc[ind]['DuplicateAppendix'] is not None:
46
+ return df[df['DuplicateAppendix'] == df.loc[ind]['DuplicateAppendix']]
47
+ else:
48
+ return df[df['DuplicateAppendix'].isna()]
49
+
50
+ def _paragraph_appendix_content(self, df, pattern: str, ind: int, shape: int) -> Tuple[List, int]:
51
+ """
52
+ Функция возвращает контент параграфа. Если в параграфе были подпункты через "-" или буквы "а, б"
53
+ Args:
54
+ df: DataFrame
55
+ pattern: Паттерн поиска.
56
+ ind: Индекс строки в DataFrame.
57
+ shape: Размер DataFrame при котором будет возвращаться пустой список.
58
+
59
+ Returns:
60
+ Возвращает список подразделов.
61
+ Examples:
62
+ 3.1. Параграф:
63
+ 1) - Содержание 1;
64
+ 2) - Содержание 2;
65
+ 3) - Содержание 3;
66
+ """
67
+ df = df[(df['PargaraphAppendix'].str.match(pattern, na=False)) | (df.index == ind)]
68
+ df = self.__check_appendix_duplicates(df, ind)
69
+
70
+ if df.shape[0] <= shape:
71
+ return [], None
72
+
73
+ start_index_paragraph = df.index[0]
74
+ paragraphs = self.__search_sub_level(df)
75
+ return paragraphs, start_index_paragraph
76
+
77
+ def _paragraph_content(self, df, pattern: str, ind: int, shape: int) -> Tuple[List, int]:
78
+ """
79
+ Функция возвращает контент параграфа. Если в параграфе были подпункты через "-" или буквы "а, б"
80
+ Args:
81
+ df: DataFrame
82
+ pattern: Паттерн поиска.
83
+ ind: Индекс строки в DataFrame.
84
+ shape: Размер DataFrame при котором будет возвращаться пустой список.
85
+
86
+ Returns:
87
+ Возвращает список подразделов.
88
+ Examples:
89
+ 3.1. Параграф:
90
+ 1) - Содержание 1;
91
+ 2) - Содержание 2;
92
+ 3) - Содержание 3;
93
+ """
94
+ df = df[
95
+ (df['Pargaraph'].str.match(pattern, na=False)) & # Проверка, соответствуют ли значения паттерну
96
+ (df['Duplicate'] == df.loc[ind]['Duplicate']) | # Оставить разделы только принадлежащие одному дубликату
97
+ (df.index == ind)] # Оставить значение, которое нашел векторный поиск
98
+ # df = self.__check_duplicates(df, ind)
99
+
100
+ if df.shape[0] <= shape:
101
+ return [], None
102
+
103
+ start_index_paragraph = df.index[0]
104
+ paragraphs = self.__search_sub_level(df)
105
+ return paragraphs, start_index_paragraph
106
+
107
+ def _paragraph_content2(self, df, pattern: str, ind: int, shape: int) -> Tuple[List, int]:
108
+ """
109
+ Функция возвращает контент параграфа. Если в параграфе были подпункты через "-" или буквы "а, б"
110
+ Args:
111
+ df: DataFrame
112
+ pattern: Паттерн поиска.
113
+ ind: Индекс строки в DataFrame.
114
+ shape: Размер DataFrame при котором будет возвращаться пустой список.
115
+
116
+ Returns:
117
+ Возвращает список подразделов.
118
+ Examples:
119
+ 3.1. Параграф:
120
+ 1) - Соде��жание 1;
121
+ 2) - Содержание 2;
122
+ 3) - Содержание 3;
123
+ """
124
+ df = df[df['Pargaraph'].str.match(pattern, na=False)]
125
+ if df.shape[0] <= shape:
126
+ return [], None
127
+ # df = self.__check_duplicates(df, ind)
128
+ # if df.shape[0] <= shape:
129
+ # return [], None
130
+ start_index_paragraph = df.index[0]
131
+ paragraphs = self.__search_sub_level(df)
132
+ return paragraphs, start_index_paragraph
133
+
134
+ @staticmethod
135
+ def _first_unknown_index(df):
136
+ indexes = list(df[df['PartLevel1'].isin(['unknown'])].index)
137
+ if len(indexes) > 0:
138
+ return df.loc[indexes[-1]]['Text']
139
+ else:
140
+ return None
141
+
142
+ def _search_other_info(self, ind, doc_number):
143
+
144
+ df = self.df[self.df['DocNumber'] == doc_number]
145
+ start_index_paragraph = df.loc[ind]['Index'] - 1
146
+ if df.loc[ind]['Table'] != 'unknown':
147
+ return df.loc[ind]['Text'], ind
148
+
149
+ if df.loc[ind]['PartLevel1'] != 'unknown':
150
+ if 'Table' in str(self.df.iloc[ind]['PartLevel1']):
151
+ return [], ind
152
+
153
+ if df.loc[ind]['Appendix'] != 'unknown':
154
+ df = df[df['Appendix'] == self.df.iloc[ind]['Appendix']]
155
+ if df.loc[ind]['LevelParagraphAppendix'] == 'unknown' and df.loc[ind]['PargaraphAppendix'] == 'unknown':
156
+ # pattern = r'\d+\.?$'
157
+ # df = df[(df['PargaraphAppendix'].str.match(pattern, na=False)) | (df.index == ind)]
158
+ # df = df[(df['LevelParagraphAppendix'] == 'Level0') | (df.index == ind)]
159
+ df = df.loc[ind:ind + 7]
160
+ start_index_paragraph = df.index[0]
161
+ paragraph = self.__search_sub_level(df)
162
+ elif df.loc[ind]['PargaraphAppendix'] != 'unknown':
163
+ pattern = df.loc[ind]["PargaraphAppendix"].replace(".", r"\.")
164
+ pattern = f'^{pattern}?\\d?.?$'
165
+ if df[df['PargaraphAppendix'].str.match(pattern, na=False)].shape[0] == 1:
166
+ pattern = df.loc[ind]["PargaraphAppendix"].replace(".", r"\.")
167
+ pattern = pattern.split('.')
168
+ pattern = [elem for elem in pattern if elem]
169
+ if len(pattern) == 1:
170
+ pattern = '.'.join(pattern)
171
+ pattern = f'^{pattern}.?\\d?.?$'
172
+ else:
173
+ pattern = '.'.join(pattern[:-1])
174
+ pattern = f'^{pattern}.\\d.?$'
175
+ df = df[df['PargaraphAppendix'].str.match(pattern, na=False)]
176
+ start_index_paragraph = df.index[0]
177
+ paragraph = self.__search_sub_level(df)
178
+ else:
179
+ paragraph = self.df.iloc[int(ind - 10):ind + 10]['Text'].values
180
+ start_index_paragraph = df.index[0]
181
+ return ' '.join(paragraph), start_index_paragraph
182
+ else:
183
+ if df.loc[ind]['Pargaraph'] == 'unknown':
184
+ header_text = self._first_unknown_index(df)
185
+ df = df.loc[int(ind - 2):ind + 2]
186
+ paragraph = self.__search_sub_level(df, header_text)
187
+ # Связан с документами без пунктов поэтому передается несколько параграфов сверху и снизу
188
+ else:
189
+ pattern = df.loc[ind]["Pargaraph"].replace(".", r"\.")
190
+ # Изет под пункты внутри пункта
191
+ paragraph, start_index_paragraph = self._paragraph_content(df, fr'^{pattern}?$', ind, 2)
192
+ if len(paragraph) == 0:
193
+ pattern = f'{pattern}\\d?.?\\d?\\d?.?$'
194
+ paragraph, start_index_paragraph = self._paragraph_content2(df, pattern, ind, 0)
195
+ if len(paragraph) == 0 and df.loc[ind]['LevelParagraph'] != '0':
196
+ pattern = df.loc[ind]["Pargaraph"].split('.')
197
+ pattern = [elem for elem in pattern if elem]
198
+ pattern = '.'.join(pattern[:-1])
199
+ pattern = f'^{pattern}\\.\\d\\d?.?$'
200
+ paragraph, start_index_paragraph = self._paragraph_content(df, pattern, ind, 0)
201
+ elif len(paragraph) == 0 and df.loc[ind]['LevelParagraph'] == '0':
202
+ pattern = df.loc[ind]["Pargaraph"].replace(".", r"\.")
203
+ if '.' not in pattern:
204
+ pattern = pattern + '\.'
205
+ pattern = f'^{pattern}\\d.?\\d?.?$'
206
+ paragraph, start_index_paragraph = self._paragraph_content(df, pattern, ind, 0)
207
+
208
+ return ' '.join(paragraph), start_index_paragraph
209
+
210
+ @staticmethod
211
+ def filter_answer(answer):
212
+ flip_answer = []
213
+ new_answer = {}
214
+ count = 0
215
+ for key in answer:
216
+ if answer[key]['start_index_paragraph'] not in flip_answer:
217
+ flip_answer.append(answer[key]['start_index_paragraph'])
218
+ new_answer[count] = answer[key]
219
+ count += 1
220
+ return new_answer
221
+
222
+ def _clear_doc_name(self, ind):
223
+ split_doc_name = self.df.iloc[ind]['DocName'].split('_')
224
+ return ' '.join(split_doc_name[1:]).replace('.txt', '').replace('.json', '').replace('.DOCX', '').replace(
225
+ '.DOC', '').replace('tables', '')
226
+
227
+ def search(self, indexes: List) -> dict:
228
+ """
229
+ Метод ищет ответы на запрос
230
+ Args:
231
+ indexes: Список индексов.
232
+
233
+ Returns:
234
+ Возвращает словарь с ответами и информацией об ответах.
235
+ """
236
+ answers = {}
237
+ for i, ind in enumerate(indexes):
238
+ answers[i] = {}
239
+ doc_number = self.df.iloc[ind]['DocNumber']
240
+ answers[i]['id'] = doc_number
241
+ answers[i][f'index_answer'] = int(ind)
242
+ answers[i][f'doc_name'] = self._clear_doc_name(ind)
243
+ answers[i][f'title'] = self.df.iloc[ind]['Title']
244
+ answers[i][f'text_answer'] = self.df.iloc[ind]['Text']
245
+
246
+ try:
247
+ other_info, start_index_paragraph = self._search_other_info(ind, doc_number)
248
+ except KeyError:
249
+ other_info, start_index_paragraph = self.df.iloc[ind]['Text'], ind
250
+ self.logger.info('Ошибка в индексе, проверьте БД!')
251
+ if len(other_info) == 0:
252
+ other_info, start_index_paragraph = self.df.iloc[ind]['Text'], ind
253
+ answers[i][f'other_info'] = [other_info]
254
+ answers[i][f'start_index_paragraph'] = int(start_index_paragraph)
255
+ return self.filter_answer(answers)
components/nmd/query_classification.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from logging import Logger
4
+ from typing import Dict, List, Optional, Tuple
5
+
6
+ from openai import OpenAI
7
+
8
+ from common.configuration import LLMConfiguration
9
+
10
+
11
+ class QueryClassification:
12
+ def __init__(self, config: LLMConfiguration, prompt: str, logger: Logger):
13
+ self.config = config
14
+ self.logger = logger
15
+ self.prompt = prompt
16
+ self.pattern = r'\[\d+\]'
17
+
18
+ # Initialize OpenAI client
19
+ if self.config.base_url is not None:
20
+ self.client = OpenAI(
21
+ base_url=self.config.base_url,
22
+ api_key=os.getenv(self.config.api_key_env)
23
+ )
24
+ else:
25
+ self.client = None
26
+
27
+ def query_classification(self, query: str) -> Tuple[str, Optional[Dict], Optional[List]]:
28
+ """
29
+ Classify the query using LLM
30
+
31
+ Args:
32
+ query: User query to classify
33
+
34
+ Returns:
35
+ Tuple containing query type, optional metadata and optional list
36
+ """
37
+ self.logger.info('Query Classification')
38
+ if self.client is None:
39
+ return '[3]', None, None
40
+ for i in range(5):
41
+ try:
42
+ response = self.client.chat.completions.create(
43
+ model=self.config.model,
44
+ messages=[
45
+ {"role": "system", "content": self.prompt},
46
+ {"role": "user", "content": query}
47
+ ],
48
+ temperature=self.config.temperature,
49
+ top_p=self.config.top_p,
50
+ frequency_penalty=self.config.frequency_penalty,
51
+ presence_penalty=self.config.presence_penalty,
52
+ seed=self.config.seed
53
+ )
54
+
55
+ answer_llm = response.choices[0].message.content
56
+ self.logger.info(f'Answer LLM {answer_llm}')
57
+
58
+ # Process the response
59
+ if re.search('%%', answer_llm):
60
+ index = re.search('%%', answer_llm).span()[1]
61
+ answer_llm = answer_llm[index:]
62
+ if re.search('Конец ответа', answer_llm):
63
+ index = re.search('Конец ответа', answer_llm).span()[1]
64
+ answer_llm = answer_llm[:index]
65
+
66
+ # Extract query type
67
+ query_type = re.findall(self.pattern, answer_llm)
68
+ if query_type:
69
+ query_type = query_type[0]
70
+ else:
71
+ query_type = '[3]'
72
+
73
+ return query_type, None, None
74
+
75
+ except Exception as e:
76
+ self.logger.error(f"Attempt {i+1} failed: {str(e)}")
77
+ if i == 4:
78
+ self.logger.error("All attempts failed")
79
+ return '[3]', None, None
components/nmd/rancker.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+ from common.configuration import Configuration
4
+
5
+
6
+ class DocumentRanking:
7
+
8
+ def __init__(self, df: pd.DataFrame, config: Configuration):
9
+ self.df = df
10
+ self.config = config
11
+ self.alpha = config.db_config.ranker.alpha
12
+ self.beta = config.db_config.ranker.beta
13
+
14
+ def doc_ranking(self, query_embedding, scores, indexes):
15
+ title_embeddings = self.df.iloc[indexes]['TitleEmbedding'].to_list()
16
+ norms = []
17
+ for emb in title_embeddings:
18
+ d = emb - query_embedding
19
+ norm = d.dot(d)
20
+ norms.append(norm)
21
+
22
+ new_score = []
23
+ texts = self.df.iloc[indexes]['Text'].to_list()
24
+ for ind, text in enumerate(texts):
25
+ new_score.append(scores[ind] * len(text) ** self.beta + self.alpha * norms[ind])
26
+
27
+ metric_df = pd.DataFrame()
28
+ metric_df['NewScores'] = new_score
29
+ metric_df['Indexes'] = indexes
30
+ metric_df.sort_values(by=['NewScores'], inplace=True)
31
+ new_indexes = metric_df['Indexes'].to_list()[:self.config.db_config.search.vector_search.k_neighbors]
32
+ return new_indexes
components/parser/README.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pipeline Module
2
+
3
+ > ВАЖНО!!! README.md сгенерировано автоматически, поэтому может содержать неточности.
4
+
5
+ Модуль реализует пайплайн для обработки XML документов и создания структурированного датасета. Пайплайн включает несколько последовательных этапов обработки, от парсинга XML до создания векторизованного датасета.
6
+
7
+ ## Основные этапы обработки
8
+
9
+ ### 1. Парсинг XML файлов
10
+ - Чтение XML файлов из указанной директории
11
+ - Извлечение текстового и табличного контента
12
+ - Сохранение метаданных документов
13
+
14
+ ### 2. Обработка аббревиатур
15
+ - Извлечение аббревиатур из текста документов
16
+ - Объединение с предварительно подготовленными аббревиатурами
17
+ - Применение аббревиатур к текстовому и табличному контенту
18
+ - Сохранение списка обнаруженных аббревиатур
19
+
20
+ ### 3. Извлечение иерархической структуры
21
+ - Парсинг структуры текстового контента
22
+ - Парсинг структуры табличного контента
23
+ - Создание иерархического представления документов
24
+
25
+ ### 4. Создание датасета
26
+ - Формирование структурированного датасета
27
+ - Векторизация текстов
28
+ - Сохранение результатов
29
+
30
+ ## Использование
31
+
32
+ ```python
33
+ from components.embedding_extraction import EmbeddingExtractor
34
+ from components.parser.pipeline import DatasetCreationPipeline
35
+ from components.parser.abbreviations.abbreviation import Abbreviation
36
+
37
+ # Инициализация пайплайна
38
+ pipeline = DatasetCreationPipeline(
39
+ dataset_id="my_dataset",
40
+ vectorizer=EmbeddingExtractor(),
41
+ prepared_abbreviations=[], # список предварительно подготовленных аббревиатур
42
+ xml_ids=["doc1", "doc2"], # список идентификаторов XML файлов
43
+ save_intermediate_files=True # сохранять ли промежуточные файлы
44
+ )
45
+
46
+ # Запуск пайплайна
47
+ dataset = pipeline.run()
48
+ ```
49
+
50
+ ## Структура выходных данных
51
+
52
+ ### Основные файлы
53
+ - `dataset.csv` - финальный датасет с векторизованными текстами
54
+ - `abbreviations.csv` - извлеченные аббревиатуры
55
+ - `xml_info.csv` - метаданные XML документов
56
+
57
+ ### Промежуточные файлы (опционально)
58
+ - `txt/*.txt` - извлеченный текстовый контент
59
+ - `txt_abbr/*.txt` - текстовый контент после применения аббревиатур
60
+ - `jsons/*.json` - иерархическая структура документов
61
+
62
+ ## Параметры конфигурации
63
+
64
+ ### DatasetCreationPipeline
65
+ - `dataset_id: str` - идентификатор создаваемого датасета
66
+ - `vectorizer: EmbeddingExtractor` - векторизатор для создания эмбеддингов
67
+ - `prepared_abbreviations: list[Abbreviation]` - предварительно подготовленные аббревиатуры
68
+ - `xml_ids: list[str]` - список идентификаторов XML файлов для обработки
69
+ - `save_intermediate_files: bool` - сохранять ли промежуточные файлы
70
+
71
+ ## Зависимости
72
+
73
+ ### Внутренние компоненты
74
+ - `components.embedding_extraction.EmbeddingExtractor`
75
+ - `components.parser.abbreviations.AbbreviationExtractor`
76
+ - `components.parser.features.HierarchyParser`
77
+ - `components.parser.features.DatasetCreator`
78
+ - `components.parser.xml.XMLParser`
79
+
80
+ ### Внешние библиотеки
81
+ - pandas
82
+ - numpy
83
+ - pathlib
84
+
85
+ ## Структура директорий
86
+
87
+ ```
88
+ data/
89
+ └── regulation_datasets/
90
+ └── {dataset_id}/
91
+ ├── abbreviations.csv
92
+ ├── xml_info.csv
93
+ ├── dataset.csv
94
+ ├── embeddings.pt
95
+ ├── txt/ # (опционально)
96
+ ├── txt_abbr/ # (опционально)
97
+ └── jsons/ # (опционально)
98
+ ```
99
+
100
+ ## Примечания
101
+
102
+ - Все промежуточные файлы сохраняются только если установлен флаг `save_intermediate_files=True`
103
+ - Векторизация выполняется после создания д��тасета
104
+ - Аббревиатуры применяются как к текстовому, так и к табличному контенту
105
+ - Иерархическая структура извлекается отдельно для текста и таблиц
components/parser/abbreviations/README.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Экстрактор сокращений (Abbreviation Extractor)
2
+
3
+ > ВАЖНО!!! README.md сгенерировано автоматически, поэтому может содержать неточности.
4
+
5
+ Модуль для извлечения сокращений и их полных форм из текстовых документов.
6
+
7
+ ## Принцип работы
8
+
9
+ Экстрактор ищет в тексте конструкции вида:
10
+ - "полная форма (далее - сокращение)"
11
+ - "полная форма (далее – сокращение)"
12
+ и подобные варианты.
13
+
14
+ ### Основные этапы обработки:
15
+
16
+ 1. **Разбиение на предложения**
17
+ - Текст разбивается на предложения с учетом специальных случаев
18
+ - Учитываются особые сокращения, после которых точка не является концом предложения
19
+
20
+ 2. **Поиск сокращений**
21
+ - В каждом предложении ищутся конструкции с маркером "далее"
22
+ - Извлекается короткая форма (сокращение) после маркера
23
+ - Определяется полная форма до маркера
24
+
25
+ 3. **Обработка сокращений**
26
+ - Поддерживается два типа сокращений:
27
+ - Однословные (например, "*БЖВРК*")
28
+ - Многословные (например, "Мы великая нация великих обезьян (далее - *нация обезьян*)")
29
+ - Для каждого сокращения определяется его полная форма
30
+
31
+ 4. **Лемматизация**
32
+ - Используется библиотека Natasha для лемматизации текста
33
+ - Помогает находить соответствия между полной и короткой формами
34
+
35
+ ## Использование
36
+
37
+ ```python
38
+ from components.parser.abbreviations.abbreviation_extractor import AbbreviationExtractor
39
+ from components.parser.xml.structures import ParsedXMLs
40
+
41
+ # Создание экстрактора
42
+ extractor = AbbreviationExtractor()
43
+
44
+ # Обработка XML-файлов
45
+ result = extractor.process_parsed_xmls(parsed_xmls)
46
+
47
+ # Обработка одного файла
48
+ file_abbreviations = extractor.process_file(text, filename)
49
+
50
+ # Извлечение сокращений из текста
51
+ abbreviations = extractor.extract_abbreviations_from_text(text)
52
+ ```
53
+
54
+ ## Структура результатов
55
+
56
+ Результаты представляются в виде структур данных:
57
+ - `AllFilesAbbreviations` - коллекция сокращений из всех файлов
58
+ - `OneFileAbbreviations` - сокращения из одного файла
59
+ - `Abbreviation` - отдельное сокращение с полной и короткой формами
60
+
61
+ ## Особенности
62
+
63
+ - Учитываются различные варианты разделителей между полной и короткой формами
64
+ - Поддерживается обработка специальных сокращений, не являющихся концом предложения
65
+ - Используется лемматизация для улучшения поиска соответствий
66
+ - Возможна обработка как одиночных файлов, так и наборов файлов
67
+
68
+ # Обработка сокращений и аббревиатур
69
+
70
+ Модуль `abbreviation.py` отвечает за обработку и нормализацию сокращений и аббревиатур в тексте.
71
+
72
+ ## Основные типы сокращений
73
+
74
+ - `ABBREVIATION` - аббревиатуры (например, "ОКС НН")
75
+ - `SHORTENING` - сокращения (например, "Компания")
76
+ - `UNKNOWN` - неопределенный тип
77
+
78
+ ## Процесс обработки
79
+
80
+ Класс `Abbreviation` выполняет следующие этапы обработки:
81
+
82
+ 1. **Определение типа сокращения** (`_define_abbreviation_type`):
83
+ - Проверяет, является ли строка аббревиатурой (содержит более одной заглавной буквы в каждом слове)
84
+ - Проверяет, является ли строка сокращением (одно слово, начинающееся с заглавной буквы)
85
+
86
+ 2. **Очистка префиксов** (`_remove_prefix`):
87
+ - Удаляет такие префиксы как "далее", различные виды тире
88
+ - Убирает лишние пробелы
89
+
90
+ 3. **Очистка от мусора** (`_remove_trash`):
91
+ - Удаляет такие подстроки как "ПАО", "ОАО", "№", "("
92
+ - Обрезает строку с начала до первого вхождения "мусорной" подстроки
93
+
94
+ 4. **Специальная обработка для аббревиатур** (`_process_abbreviation`):
95
+ - Извлекает заглавные буквы из короткой формы
96
+ - Проверяет соответствие заглавных букв началам слов в полной форме
97
+ - Обрезает полную форму до релевантной части
98
+
99
+ 5. **Специальная обработка для сокращений** (`_process_shortening`):
100
+ - Применяет стемминг (с помощью алгоритма Портера) к короткой форме
101
+ - Обрезает полную форму до релевантной части
102
+
103
+ ## Валидация
104
+
105
+ - Проверяет длину полной формы (должна быть меньше MAX_LENGTH)
106
+ - Проверяет, что полная форма длиннее короткой
107
+ - Проверяет отсутствие полной формы в черном списке (BLACKLIST)
108
+ - Для аббревиатур проверяет соответствие заглавных букв началам слов
109
+ - Для сокращений проверяет корректность регистра букв и отсутствие специальных случаев
110
+
111
+ Если какая-либо проверка не проходит, тип сокращения устанавливается как `UNKNOWN`.
112
+
113
+
114
+ # Применение сокращений и аббревиатур
115
+
116
+ Класс `Abbreviation` имеет метод `apply`, который принимает текст и возвращает текст с примененными сокращениями и аббревиатурами.
117
+
118
+ Класс
119
+
components/parser/abbreviations/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from .abbreviation import Abbreviation
2
+ from .abbreviation_extractor import AbbreviationExtractor
3
+ from .structures import AbbreviationsCollection
4
+
5
+ __all__ = [
6
+ "AbbreviationExtractor",
7
+ "Abbreviation",
8
+ "AbbreviationsCollection",
9
+ ]
components/parser/abbreviations/abbreviation.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+ from components.parser.abbreviations.constants import (
6
+ ABBREVIATION_CLEANUP_REPLACEMENTS,
7
+ BLACKLIST,
8
+ DASH_PATTERN,
9
+ MAX_LENGTH,
10
+ PREFIX_PARTS_TO_REMOVE,
11
+ REMOVING_SUBSTRINGS,
12
+ )
13
+ from components.parser.abbreviations.porter import Porter
14
+
15
+
16
+ class AbbreviationType(str, Enum):
17
+ ABBREVIATION = 'abbreviation'
18
+ SHORTENING = 'shortening'
19
+ UNKNOWN = 'unknown'
20
+
21
+
22
+ @dataclass
23
+ class Abbreviation:
24
+ short_form: str
25
+ full_form: str
26
+ abbreviation_type: AbbreviationType = AbbreviationType.UNKNOWN
27
+
28
+ _processed: bool = False
29
+ document_id: int | None = None
30
+
31
+ def process(self) -> 'Abbreviation':
32
+ """
33
+ Производит пост-обработку сокращения и полной формы.
34
+ - Определяет тип сокращения.
35
+ - Удаляет префикс из короткой формы и мусор из полной формы.
36
+ - В зависимости от типа сокращения адаптирует его под нужный вид.
37
+ """
38
+ if self._processed:
39
+ return
40
+
41
+ self._define_abbreviation_type()
42
+
43
+ self.short_form = self._remove_prefix(self.short_form)
44
+ self.full_form = self._remove_trash(self.full_form)
45
+
46
+ if self._abbreviation_type == AbbreviationType.SHORTENING:
47
+ self._process_shortening()
48
+ elif self._abbreviation_type == AbbreviationType.ABBREVIATION:
49
+ self._process_abbreviation()
50
+
51
+ self._processed = True
52
+
53
+ return self
54
+
55
+ def apply(self, text: str) -> str:
56
+ """
57
+ Применяет аббревиатуру к тексту.
58
+
59
+ Args:
60
+ text (str): Текст для обработки.
61
+
62
+ Returns:
63
+ str: Обработанный текст.
64
+ """
65
+ if self._abbreviation_type == AbbreviationType.UNKNOWN:
66
+ return text
67
+
68
+ if self._abbreviation_type == AbbreviationType.SHORTENING:
69
+ return self._apply_shortening(text)
70
+
71
+ elif self._abbreviation_type == AbbreviationType.ABBREVIATION:
72
+ return self._apply_abbreviation(text)
73
+
74
+ def _apply_shortening(self, text: str) -> str:
75
+ """
76
+ Применяет сокращение к тексту.
77
+
78
+ Args:
79
+ text (str): Текст для обработки.
80
+
81
+ Returns:
82
+ str: Обработанный текст.
83
+ """
84
+ matches = list(re.finditer(self.short_form, text))
85
+ for i in range(len(matches) - 1, 1, -1):
86
+ m = matches[i]
87
+ pos1 = m.start()
88
+ m2 = re.match(r'[A-Za-zА-Яа-я]+', text[pos1:])
89
+ pos2 = pos1 + m2.end()
90
+ explanation = self.full_form
91
+ m3 = re.match(r'[A-Za-zА-Яа-я]+', explanation)
92
+ explanation = explanation[m3.end() :]
93
+ text = text[:pos2] + explanation + text[pos2:]
94
+ return text
95
+
96
+ def _apply_abbreviation(self, text: str) -> str:
97
+ """
98
+ Применяет аббревиатуру к тексту.
99
+
100
+ Args:
101
+ text (str): Текст для обработки.
102
+
103
+ Returns:
104
+ str: Обработанный текст.
105
+ """
106
+ matches = list(re.finditer(self.short_form, text))
107
+ for i in range(len(matches) - 1, 0, -1):
108
+ m = matches[i]
109
+ text = f'{text[: m.start()]}{self.short_form} ({self.full_form}){text[m.end():]}'
110
+ return text
111
+
112
+ def _define_abbreviation_type(self) -> None:
113
+ """
114
+ Определяет тип сокращения.
115
+ """
116
+ if self._check_abbreviation(self.full_form):
117
+ self._abbreviation_type = AbbreviationType.ABBREVIATION
118
+ elif self._check_shortening(self.full_form):
119
+ self._abbreviation_type = AbbreviationType.SHORTENING
120
+ else:
121
+ self._abbreviation_type = AbbreviationType.UNKNOWN
122
+
123
+ def _process_shortening(self) -> None:
124
+ """
125
+ Обрабатывает сокращение.
126
+ """
127
+ key = Porter.stem(self.short_form)
128
+ pos = self.full_form.lower().rfind(key.lower())
129
+ if pos != -1:
130
+ self.full_form = self.full_form[pos:]
131
+ self.short_form = key
132
+ else:
133
+ self.abbreviation_type = AbbreviationType.UNKNOWN
134
+
135
+ def _process_abbreviation(self) -> None:
136
+ """
137
+ Обрабатывает аббревиатуру.
138
+ """
139
+ uppercase_letters = re.sub('[a-zа-я, ]', '', self.short_form)
140
+ processed_full_form = self._remove_trash_when_abbreviation(self.full_form)
141
+ words = processed_full_form.split()
142
+ uppercase_letters = uppercase_letters[::-1]
143
+ words = words[::-1]
144
+
145
+ if (len(words) <= len(uppercase_letters)) or ('ОКС НН' not in self.short_form):
146
+ self.abbreviation_type = AbbreviationType.UNKNOWN
147
+ return
148
+
149
+ match = self._check_abbreviation_matches_words(uppercase_letters, words)
150
+ if match:
151
+ self._process_matched_abbreviation(uppercase_letters, words)
152
+ else:
153
+ self._process_mismatched_abbreviation()
154
+
155
+ def _process_matched_abbreviation(
156
+ self,
157
+ uppercase_letters: str,
158
+ words: list[str],
159
+ ) -> None:
160
+ """
161
+ Обрабатывает аббревиатуру, которая совпадает с первыми буквами полной формы.
162
+
163
+ Args:
164
+ uppercase_letters (str): Заглавные буквы из сокращения.
165
+ words (list[str]): Список слов, которые составляют аббревиатуру.
166
+ """
167
+ pos = len(self.full_form)
168
+ for i in range(len(uppercase_letters)):
169
+ pos = self.full_form.rfind(words[i], 0, pos)
170
+
171
+ if pos != -1:
172
+ self.full_form = self.full_form[pos:]
173
+
174
+ else:
175
+ self.abbreviation_type = AbbreviationType.UNKNOWN
176
+
177
+ def _process_mismatched_abbreviation(self) -> None:
178
+ """
179
+ Обрабатывает аббревиатуру, которая не совпадает с первыми буквами полной формы.
180
+ """
181
+ first_letter = self.short_form[0]
182
+ pos = self.full_form.rfind(first_letter)
183
+ if pos != -1:
184
+ self.full_form = self.full_form[pos:]
185
+ first_letter = self.full_form[0]
186
+ second_letter = self.full_form[1]
187
+
188
+ if (
189
+ ('A' < first_letter < 'Z' or 'А' < first_letter < 'Я')
190
+ and ('a' < second_letter < 'z' or 'а' < second_letter < 'я')
191
+ and len(self.full_form) < MAX_LENGTH
192
+ and len(self.full_form) > len(self.short_form)
193
+ and self.full_form not in BLACKLIST
194
+ and '_' not in self.full_form
195
+ ):
196
+ return
197
+
198
+ self.abbreviation_type = AbbreviationType.UNKNOWN
199
+
200
+ def _check_abbreviation_matches_words(
201
+ self,
202
+ uppercase_letters: str,
203
+ words: list[str],
204
+ ) -> bool:
205
+ """
206
+ Проверяет, соответствует ли короткая форма аббревиатуре.
207
+
208
+ Args:
209
+ uppercase_letters (str): Заглавные буквы из сокращения.
210
+ words (list[str]): Список слов, которые составляют аббревиатуру.
211
+
212
+ Returns:
213
+ bool: True, если аббревиатура соответствует, False в противном случае.
214
+ """
215
+ for j in range(len(uppercase_letters)):
216
+ c1 = uppercase_letters[j].lower()
217
+ c2 = words[j][0].lower()
218
+ if c1 != c2:
219
+ return False
220
+
221
+ return True
222
+
223
+ @classmethod
224
+ def _check_abbreviation(cls, full_form: str) -> bool:
225
+ """
226
+ Проверяет, является ли строка аббревиатурой.
227
+
228
+ Args:
229
+ full_form (str): Строка для проверки.
230
+
231
+ Returns:
232
+ bool: True, если строка является аббревиатурой, False в противном случае.
233
+ """
234
+ s = cls._remove_prefix(full_form)
235
+ words = s.split()
236
+
237
+ for word in words:
238
+ n = cls._count_uppercase_letters(word)
239
+ if (n <= 1) and (word != 'и'):
240
+ return False
241
+
242
+ return True
243
+
244
+ @classmethod
245
+ def _check_shortening(cls, full_form: str) -> bool:
246
+ """
247
+ Проверяет, является ли строка сокращением.
248
+
249
+ Args:
250
+ full_form (str): Строка для проверки.
251
+
252
+ Returns:
253
+ bool: True, если строка является сокращением, False в противном случае.
254
+ """
255
+ s = cls._remove_prefix(full_form)
256
+ words = s.split()
257
+
258
+ if len(words) != 1:
259
+ return False
260
+
261
+ word = words[0]
262
+ if word[0].isupper() and word[1:].islower() and ('Компания' not in word):
263
+ return True
264
+
265
+ return False
266
+
267
+ @staticmethod
268
+ def _remove_prefix(s: str) -> str:
269
+ """
270
+ Удаляет из строки префиксы типа "далее - " и "далее – ".
271
+
272
+ Args:
273
+ s (str): Строка для обработки.
274
+
275
+ Returns:
276
+ str: Обработанная строка.
277
+ """
278
+ for prefix_part in PREFIX_PARTS_TO_REMOVE:
279
+ s = s.replace(prefix_part, '')
280
+ return s.strip()
281
+
282
+ @staticmethod
283
+ def _remove_trash(s: str) -> str:
284
+ """
285
+ Удаляет из строки такие подстроки, как "ПАО", "ОАО", "№", "(".
286
+
287
+ Args:
288
+ s (str): Строка для обработки.
289
+
290
+ Returns:
291
+ str: Обработанная строка.
292
+ """
293
+ for substring in REMOVING_SUBSTRINGS:
294
+ pos = s.find(substring)
295
+ if pos != -1:
296
+ s = s[:pos]
297
+ return s
298
+
299
+ @staticmethod
300
+ def _remove_trash_when_abbreviation(s: str) -> str:
301
+ """
302
+ Удаляет из строки такие подстроки, как " и ", " или ", ", ", " ГО".
303
+ Заменяет дефисы и тире на пробел.
304
+ Это необходимо для того, чтобы правильно сопоставить аббревиатуру с полной формой.
305
+
306
+ Args:
307
+ s (str): Строка для обработки.
308
+
309
+ Returns:
310
+ str: Обработанная строка.
311
+ """
312
+ for old, new in ABBREVIATION_CLEANUP_REPLACEMENTS.items():
313
+ s = s.replace(old, new)
314
+ s = re.sub(DASH_PATTERN, ' ', s)
315
+ return s
316
+
317
+ @staticmethod
318
+ def _count_uppercase_letters(s: str) -> int:
319
+ """
320
+ Считает количество заглавных букв в строке.
321
+
322
+ Args:
323
+ s (str): Строка для обработки.
324
+
325
+ Returns:
326
+ int: Количество заглавных букв.
327
+ """
328
+ return len(re.findall(r'[A-Z,А-Я]', s))
components/parser/abbreviations/abbreviation_extractor.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ from natasha import Doc, MorphVocab, NewsEmbedding, NewsMorphTagger, Segmenter
4
+
5
+
6
+ from .constants import (
7
+ ABBREVIATION_RE,
8
+ CLOSE_BRACKET_RE,
9
+ FIRST_CHARS_SET,
10
+ NEXT_MARKER_RE,
11
+ NON_SENTENCE_ENDINGS,
12
+ SECOND_CHARS_SET,
13
+ UPPERCASE_LETTER_RE,
14
+ )
15
+ from .structures import Abbreviation
16
+
17
+
18
+ class AbbreviationExtractor:
19
+ def __init__(self):
20
+ """
21
+ Инициализация экстрактора сокращений.
22
+
23
+ Создает необходимые компоненты для лемматизации и компилирует регулярные выражения.
24
+ """
25
+ # Инициализация компонентов Natasha для лемматизации
26
+ self.segmenter = Segmenter()
27
+ self.morph_tagger = NewsMorphTagger(NewsEmbedding())
28
+ self.morph_vocab = MorphVocab()
29
+
30
+ # Компиляция регулярных выражений
31
+ self.next_re = re.compile(NEXT_MARKER_RE, re.IGNORECASE)
32
+ self.abbreviation_re = re.compile(ABBREVIATION_RE)
33
+ self.uppercase_letter_re = re.compile(UPPERCASE_LETTER_RE)
34
+ self.close_bracket_re = re.compile(CLOSE_BRACKET_RE)
35
+
36
+ self.delimiters = [
37
+ f'{char1} {char2} '.format(char1, char2)
38
+ for char1 in FIRST_CHARS_SET
39
+ for char2 in SECOND_CHARS_SET
40
+ ]
41
+
42
+ def extract_abbreviations_from_text(
43
+ self,
44
+ text: str,
45
+ ) -> list[Abbreviation]:
46
+ """
47
+ Извлечение всех сокращений из текста.
48
+
49
+ Args:
50
+ text: Текст для обработки
51
+
52
+ Returns:
53
+ list[Abbreviation]: Список найденных сокращений
54
+ """
55
+ sentences = self._extract_sentences_with_abbreviations(text)
56
+
57
+ abbreviations = [self._process_one_sentence(sentence) for sentence in sentences]
58
+ abbreviations = sum(abbreviations, []) # делаем список одномерным
59
+ abbreviations = [abbreviation.process() for abbreviation in abbreviations]
60
+
61
+ return abbreviations
62
+
63
+ def _process_one_sentence(self, sentence: str) -> list[Abbreviation]:
64
+ """
65
+ Обработка одного предложения для извлечения сокращений.
66
+
67
+ Args:
68
+ sentence: Текст для обработки
69
+
70
+ Returns:
71
+ list[Abbreviation]: Список найденных сокращений
72
+ """
73
+ search_iter = self.next_re.finditer(sentence)
74
+ prev_index = 0
75
+
76
+ abbreviations = []
77
+
78
+ for match in search_iter:
79
+ abbreviation, prev_index = self._process_match(sentence, match, prev_index)
80
+ if abbreviation is not None:
81
+ abbreviations.append(abbreviation)
82
+
83
+ return abbreviations
84
+
85
+ def _process_match(
86
+ self,
87
+ sentence: str,
88
+ match: re.Match,
89
+ prev_index: int,
90
+ ) -> tuple[Abbreviation | None, int]:
91
+ """
92
+ Обработка одного совпадения с конструкцией "далее - {short_form}" для извлечения сокращений.
93
+
94
+ Args:
95
+ sentence: Текст для обработки
96
+ match: Совпадение для обработки
97
+ prev_index: Предыдущий индекс
98
+
99
+ Returns:
100
+ tuple[Abbreviation | None, int]: Найденное сокращение (None, если нет сокращения) и следующий индекс
101
+ """
102
+ start, end = match.start(), match.end()
103
+ text = sentence[start:]
104
+
105
+ index_close_parenthesis = self._get_close_parenthesis_index(text)
106
+ index_point = self._get_point_index(text, start)
107
+
108
+ prev_index += index_point
109
+ short_word = text[end : start + index_close_parenthesis].strip()
110
+
111
+ if len(short_word.split()) < 2:
112
+ abbreviation = self._process_match_for_word(
113
+ short_word, text, start, end, prev_index
114
+ )
115
+
116
+ else:
117
+ abbreviation = self._process_match_for_phrase(
118
+ short_word, text, start, end, prev_index
119
+ )
120
+
121
+ prev_index = start + index_close_parenthesis + 1
122
+
123
+ return abbreviation, prev_index
124
+
125
+ def _get_close_parenthesis_index(self, text: str) -> int:
126
+ """
127
+ Получение индекса закрывающей скобки в тексте.
128
+
129
+ Args:
130
+ text: Текст для обработки
131
+
132
+ Returns:
133
+ int: Индекс закрывающей скобки или 0, если не найдено
134
+ """
135
+ result = self.close_bracket_re.search(text)
136
+ if result is None:
137
+ return 0
138
+ return result.start()
139
+
140
+ def _get_point_index(self, text: str, start_index: int) -> int:
141
+ """
142
+ Получение индекса точки в тексте.
143
+
144
+ Args:
145
+ text: Текст для обработки
146
+ start_index: Индекс начала поиска
147
+
148
+ Returns:
149
+ int: Индекс точки или 0, если не найдено
150
+ """
151
+ result = text.rfind('.', 0, start_index - 1)
152
+ if result == -1:
153
+ return 0
154
+ return result
155
+
156
+ def _process_match_for_word(
157
+ self,
158
+ short_word: str,
159
+ text: str,
160
+ start_next_re_index: int,
161
+ end_next_re_index: int,
162
+ prev_index: int,
163
+ ) -> Abbreviation | None:
164
+ """
165
+ Обработка сокращения, состоящего из одного слова.
166
+
167
+ Args:
168
+ short_word: Сокращение
169
+ text: Текст для обработки
170
+ start_next_re_index: Индекс начала следующего совпадения
171
+ end_next_re_index: Индекс конца следующего совпадения
172
+ prev_index: Предыдущий индекс
173
+
174
+ Returns:
175
+ Abbreviation | None: Найденное сокращение или None, если нет сокращения
176
+ """
177
+ if self.abbreviation_re.findall(text) or (short_word == 'ПДн'):
178
+ return None
179
+
180
+ lemm_text = self._lemmatize_text(text[prev_index:start_next_re_index])
181
+ lemm_short_word = self._lemmatize_text(short_word)
182
+
183
+ search_word = re.search(lemm_short_word, lemm_text)
184
+
185
+ if not search_word:
186
+ start_text_index = self._get_start_text_index(
187
+ text,
188
+ start_next_re_index,
189
+ prev_index,
190
+ )
191
+
192
+ if start_text_index is None:
193
+ return None
194
+
195
+ full_text = text[prev_index + start_text_index : end_next_re_index]
196
+
197
+ else:
198
+ index_word = search_word.span()[1]
199
+ space_index = text[prev_index:start_next_re_index].rfind(' ', 0, index_word)
200
+ if space_index == -1:
201
+ space_index = 0
202
+ text = text[prev_index + space_index : start_next_re_index]
203
+
204
+ full_text = text.replace(')', '').replace('(', '').replace('', '- ')
205
+
206
+ return Abbreviation(
207
+ short_form=short_word,
208
+ full_form=full_text,
209
+ )
210
+
211
+ def _process_match_for_phrase(
212
+ self,
213
+ short_word: str,
214
+ text: str,
215
+ start_next_re_index: int,
216
+ end_next_re_index: int,
217
+ prev_index: int,
218
+ ) -> list[Abbreviation] | None:
219
+ """
220
+ Обработка сокращения, состоящего из нескольких слов.
221
+ В действительности производится обработка первого слова сокращения, а затем вместо него подставляется полное сокращение.
222
+
223
+ Args:
224
+ short_word: Сокращение
225
+ text: Текст для обработки
226
+ start_next_re_index: Индекс начала следующего совпадения
227
+ end_next_re_index: Индекс конца следующего совпадения
228
+ prev_index: Предыдущий индекс
229
+
230
+ Returns:
231
+ list[Abbreviation] | None: Найденные сокращения или None, если нет сокращений
232
+ """
233
+ first_short_word = short_word.split()[0]
234
+ result = self._process_match_for_word(
235
+ first_short_word, text, start_next_re_index, end_next_re_index, prev_index
236
+ )
237
+ if result is None:
238
+ return None
239
+ return Abbreviation(
240
+ short_form=short_word,
241
+ full_form=result.full_form,
242
+ )
243
+
244
+ def _get_start_text_index(
245
+ self,
246
+ text: str,
247
+ start_next_re_index: int,
248
+ prev_index: int,
249
+ ) -> int | None:
250
+ """
251
+ Получение индекса начала текста для поиска сокращения с учётом разделителей типа
252
+ "; - "
253
+ ": - "
254
+ ";  "
255
+ ": ‒ " и т.п.
256
+
257
+ Args:
258
+ text: Текст для обработки
259
+ start_next_re_index: Индекс начала следующего совпадения
260
+ prev_index: Предыдущий индекс
261
+
262
+ Returns:
263
+ int | None: Индекс начала текста или None, если не найдено
264
+ """
265
+ if prev_index == 0:
266
+ return 0
267
+
268
+ for delimiter in self.delimiters:
269
+ result = re.search(delimiter, text[prev_index:start_next_re_index])
270
+ if result is not None:
271
+ return result.span()[1]
272
+
273
+ return None
274
+
275
+ def _lemmatize_text(self, text: str) -> str:
276
+ """
277
+ Лемматизация текста.
278
+
279
+ Args:
280
+ text: Текст для лемматизации
281
+
282
+ Returns:
283
+ str: Лемматизированный текст
284
+ """
285
+ doc = Doc(text)
286
+ doc.segment(self.segmenter)
287
+ doc.tag_morph(self.morph_tagger)
288
+
289
+ for token in doc.tokens:
290
+ token.lemmatize(self.morph_vocab)
291
+
292
+ return ' '.join([token.lemma for token in doc.tokens])
293
+
294
+ def _extract_sentences_with_abbreviations(self, text: str) -> list[str]:
295
+ """
296
+ Разбивает текст на предложения с учетом специальных сокращений.
297
+
298
+ Точка после сокращений из NON_SENTENCE_ENDINGS не считается концом предложения.
299
+
300
+ Args:
301
+ text: Текст для разбиения
302
+
303
+ Returns:
304
+ list[str]: Список предложений
305
+ """
306
+ text = text.replace('\n', ' ')
307
+ sentence_endings = re.finditer(r'\.\s+[А-Я]', text)
308
+
309
+ sentences = []
310
+ start = 0
311
+
312
+ for match in sentence_endings:
313
+ end = match.start() + 1
314
+
315
+ # Проверяем, не заканчивается ли предложение на специальное сокращение
316
+ preceding_text = text[start:end]
317
+ words = preceding_text.split()
318
+
319
+ if words and any(
320
+ words[-1].rstrip('.').startswith(abbr) for abbr in NON_SENTENCE_ENDINGS
321
+ ):
322
+ continue
323
+
324
+ sentence = text[start:end].strip()
325
+ sentences.append(sentence)
326
+ start = end + 1
327
+
328
+ # Добавляем последнее предложение
329
+ if start < len(text):
330
+ sentences.append(text[start:].strip())
331
+
332
+ return [
333
+ sentence
334
+ for sentence in sentences
335
+ if self.next_re.search(sentence) is not None
336
+ ]
components/parser/abbreviations/constants.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Регулярные выражения
2
+ NEXT_MARKER_RE = r'далее (--|־|᠆|‐|‑|‒|–|—|―|⸺|⸻|﹘|﹣|-|-|-)'
3
+ ABBREVIATION_RE = (
4
+ r'\b[А-Я0-9]{1,}(?:\s?[А-Я0-9]{1,}|[:\-.]?[А-Я0-9]{1,}|[а-я]{1}[А-Я0-9]{1,})*\b'
5
+ )
6
+ UPPERCASE_LETTER_RE = r'[A-ZА-Я]'
7
+ CLOSE_BRACKET_RE = r'\)'
8
+
9
+ # Сокращения, после которых точка не означает конец предложения
10
+ NON_SENTENCE_ENDINGS = ['г', 'д-р', 'т.е', 'и т.д', 'и т.п', 'и т.п.', 'ул', 'пр']
11
+
12
+ FIRST_CHARS_SET = {'.', ':', ';'}
13
+ SECOND_CHARS_SET = {
14
+ '‒',
15
+ '–',
16
+ '—',
17
+ '―',
18
+ '⸺',
19
+ '⸻',
20
+ '﹘',
21
+ '﹣',
22
+ '-',
23
+ '-',
24
+ '-',
25
+ '-',
26
+ '\uf0b7',
27
+ '',
28
+ }
29
+
30
+ BLACKLIST = [
31
+ 'Ненецкого муниципального района',
32
+ 'Изменение идентифицирующих',
33
+ 'Systems, Applications and Products in Data Processing Enterprise Resource Planning',
34
+ 'Российской Федерации, Уставом',
35
+ 'Собственника Объекта защиты',
36
+ ]
37
+
38
+ REMOVING_SUBSTRINGS = ['ПАО', 'ОАО', '№', '(']
39
+
40
+ MAX_LENGTH = 100
41
+
42
+ # Strings to remove from abbreviations
43
+ PREFIX_PARTS_TO_REMOVE = ['далее', '–', '-']
44
+
45
+ # Strings to remove when processing abbreviations
46
+ ABBREVIATION_CLEANUP_REPLACEMENTS = {
47
+ ' и ': ' ',
48
+ ' или ': ' ',
49
+ ', ': ' ',
50
+ ' ГО': ' ',
51
+ }
52
+
53
+ # Regex pattern for dashes/hyphens to be replaced with space
54
+ DASH_PATTERN = '(-|-|־|᠆|‐|‑|‒|–|—|―|⸺|⸻|﹘|﹣|-)'