muryshev commited on
Commit
0341212
·
1 Parent(s): 383ba14
common/configuration.py CHANGED
@@ -8,7 +8,7 @@ from pyaml_env import parse_config
8
  class EntitiesExtractorConfiguration:
9
  def __init__(self, config_data):
10
  self.strategy_name = str(config_data['strategy_name'])
11
- self.strategy_params: dict = config_data['strategy_params']
12
  self.process_tables = bool(config_data['process_tables'])
13
  self.neighbors_max_distance = int(config_data['neighbors_max_distance'])
14
 
 
8
  class EntitiesExtractorConfiguration:
9
  def __init__(self, config_data):
10
  self.strategy_name = str(config_data['strategy_name'])
11
+ self.strategy_params: dict | None = config_data['strategy_params']
12
  self.process_tables = bool(config_data['process_tables'])
13
  self.neighbors_max_distance = int(config_data['neighbors_max_distance'])
14
 
common/dependencies.py CHANGED
@@ -19,6 +19,7 @@ from components.services.document import DocumentService
19
  from components.services.entity import EntityService
20
  from components.services.llm_config import LLMConfigService
21
  from components.services.llm_prompt import LlmPromptService
 
22
 
23
 
24
  def get_config() -> Configuration:
@@ -131,3 +132,16 @@ def get_dialogue_service(
131
  llm_api=llm_api,
132
  llm_config_service=llm_config_service,
133
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from components.services.entity import EntityService
20
  from components.services.llm_config import LLMConfigService
21
  from components.services.llm_prompt import LlmPromptService
22
+ from components.services.search_metrics import SearchMetricsService
23
 
24
 
25
  def get_config() -> Configuration:
 
132
  llm_api=llm_api,
133
  llm_config_service=llm_config_service,
134
  )
135
+
136
+
137
+ def get_search_metrics_service(
138
+ entity_service: Annotated[EntityService, Depends(get_entity_service)],
139
+ config: Annotated[Configuration, Depends(get_config)],
140
+ dialogue_service: Annotated[DialogueService, Depends(get_dialogue_service)],
141
+ ) -> SearchMetricsService:
142
+ """Получение сервиса для расчета метрик поиска через DI."""
143
+ return SearchMetricsService(
144
+ entity_service=entity_service,
145
+ config=config,
146
+ dialogue_service=dialogue_service,
147
+ )
components/llm/prompts.py CHANGED
@@ -1,8 +1,8 @@
1
  SYSTEM_PROMPT = """
2
  Ты профессиональный банковский рекрутёр
3
- ####
4
  Инструкция для составления ответа
5
- ####
6
  Твоя задача - ответить максимально корректно на запрос пользователя по теме рекрутинга, используя информацию по запросу. Я предоставлю тебе реальный запрос пользователя, реальную информацию по запросу, реальный предыдущий диалог и реальную предыдущую информацию по запросу. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
7
  - Отвечай ТОЛЬКО на русском языке.
8
  - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
@@ -27,9 +27,9 @@ SYSTEM_PROMPT = """
27
  user - это сообщения пользователя.
28
  <search-results>Вот тут будет найденная информация по запросу пользователя</search-results>
29
  assistant - это твои ответы на запросы пользователя.
30
- ####
31
  Пример 1
32
- ####
33
  user: А в какие сроки на меня нужно направить характеристику для аттестации?
34
  <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
35
  Характеристика на работника, подлежащего аттестации, вместе с копией должностной инструкции представляется в аттестационную комиссию не позднее чем за 10 дней до начала аттестации.</search-results>
@@ -44,26 +44,26 @@ 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: Я профессиональный помощник рекрутёра. Вы можете задавать мне любые вопросы по подготовленным документам.
@@ -81,22 +81,22 @@ user: Что такое корпоративное управление банк
81
  Комитеты в рамках вопросов, относящихся к их компетенции:
82
  анализируют процесс реализации целей и стратегии развития банка, выполнения решений совета директоров;
83
  представляют на рассмотрение совета директоров свои рекомендации и регулярные отчеты о деятельности банка.</search-results>
84
- ####
85
  assistant: Вы задали несколько вопросов и я отвечу на каждый из них отдельно:
86
  1. Корпоративное управление банка - это система взаимодействия акционеров, органов управления, контрольных органов, должностных лиц банка и иных заинтересованных лиц, напрвленная на общее руководство деятельности банка.
87
  2. Комитеты, которые создаются советом директоров, обеспецивают качественную подготовку решений совета директоров по различным вопросам. Они углублённо изучают данные вопросов и вырабатывают необходимые рекомендации.
88
  3. Извините, я не знаю как ответить на этот вопрос. Он не касается темы рекрутинга или я не совсем понимаю его контекст.
89
  4. Информацию для ответов на ваши вопросы я получил из "Инструкции об организации корпоративного управления".
90
- ####
91
  Далее будет реальный запрос пользователя. Ты должен ответить только на реальный запрос пользователя.
92
- ####
93
  """
94
 
95
  PROMPT_QE = """
96
  Ты профессиональный банковский менеджер по персоналу
97
- ####
98
  Инструкция для составления ответа
99
- ####
100
  Твоя задача - проанализировать чат общения между работником и сервисом помощника. Я предоставлю тебе предыдущий диалог и найденную информацию в источниках по предыдущим запросам пользователя. Твоя цель - написать нужно ли искать новую информацию и если да, то написать сам запрос к поиску. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
101
  - Отвечай ТОЛЬКО на русском языке.
102
  - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
@@ -126,9 +126,9 @@ PROMPT_QE = """
126
  3. 'пункт 3'
127
  4. 'пункт 4'
128
  "
129
- ####
130
  Пример 1
131
- ####
132
  user: А в какие сроки на меня нужно направить характеристику для аттестации?
133
  <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
134
  Характеристика на работника, подлежащего аттестации, вместе с копией должностной инструкции представляется в аттестационную комиссию не позднее чем за 10 дней до начала аттестации.</search-results>
@@ -141,25 +141,25 @@ user: Я волнуюсь. А как она проводится?
141
  На заседании комиссии ведется протокол, который подписывается председателем и секретарем комиссии, являющимися одновременно членами комиссии с правом голоса.</search-results>
142
  assistant: Не переживайте. Аттестация проводится в очной форме в виде собеседования. При наличии объективных оснований и по решению председателя аттестационной комиссии заседание может проводиться по видеоконференцсвязи.
143
  user: А кто будет участвовать?
144
- ####
145
  Вывод:
146
  1. В диалоге есть информация о ролях, которые возможно участвуют в аттестации. Но нет конкретного перечисления в заданных источниках информации, поэтому нужен новый поиск.
147
  2. [ДА]
148
  3. Итоговый запрос "А кто будет участвовать?". Но он не даёт полной картины из-за потери контекста. Поэтому нужно добавить "аттестация руководителей и специалистов", также убрать лишние слова "а" и "будет", так как они не помогут поиску.
149
  4. [Кто участвует в аттестации руководителей и специалистов?]
150
- ####
151
  Пример 2
152
- ####
153
  user: Здравствуйте. Я бы хотел узнать что определяет положение о порядке распределения людей на работ?
154
- ####
155
  Вывод:
156
  1. В приведённом примере только запрос пользователя. Результатов поиска нет, поэтому нужно искать.
157
  2. [ДА]
158
  3. Запрос сформулирован почти корректно. Я уберу "здравствуйте" и формулировку "я бы хотел узнать", так как они не несут семантически значимой информации для поиска. Также слово "работ" перепишу корректно в "работу".
159
  4. [Что определяет положение о порядке распределения людей на работу?]
160
- ####
161
  Пример 3
162
- ####
163
  user: Привет! Кто ты?
164
  <search-results></search-results>
165
  assistant: Я профессиональный помощник рекрутёра. Вы можете задавать мне любые вопросы по подготовленным документам.
@@ -170,37 +170,196 @@ user: Где питается слон?
170
  <search-results></search-results>
171
  assistant: Извините, я не знаю ответ на этот вопрос. Он не касается рекрутинга. Попробуйте переформулировать.
172
  user: Что такое корпоративное управление банка? Зачем нужны комитеты? Где собака зарыта? Откуда ты всё знаешь?
173
- ####
174
  Вывод:
175
  1. Пользователь задаёт вопросы как по тематике персонала, так и вне него. Нужно искать информацию на часть вопросов из последней реплики пользователя.
176
  2. [ДА]
177
  3. Первый вопрос про корпоративное управление не содержит лишнего. Второй вопрос требует заменить "зачем" на "цель" и "задачи". Вопрос про собаку вне тематики рекрутинга, я не буду его переписыва��ь. Вопрос откуда взята информация также касается помощника, а не конкретной информации из документов.
178
  4. [Что такое корпоративное управление банка? Каковы задачи и цели комитетов?]
179
- ####
180
  Пример 4
181
- ####
182
  user: Сегодня я буду покупать груши. Какая погода?
183
- ####
184
  Вывод:
185
  1. Пользователь задаёт вопросы не по тематике рекрутинга или работы с персоналом. Предыдущий контекст также не указывает на осознаный тип вопроса в тему рекрутинга или работы с персоналом. Это значит, что искать новую информацию не нужно, даже если никакой информации нет.
186
  2. [НЕТ]
187
  3. Рассуждения не требуются.
188
  4. []
189
- ####
190
  Пример 5
191
- ####
192
  user: Привет. Хочешь поговорить?
193
- ####
194
  Вывод:
195
  1. Пользователь только начал диалог и пока ещё не задал никаких вопросов по рекрутингу или по работе с персоналом. Это значит, что искать информацию не нужно.
196
  2. [НЕТ]
197
  3. Рассуждения не требуются.
198
  4. []
199
- ####
200
  Далее будет реальный запрос пользователя. Ты должен ответить только на реальный запрос пользователя.
201
- ####
202
  {history}
203
- ####
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  Вывод:
205
  """
206
 
 
1
  SYSTEM_PROMPT = """
2
  Ты профессиональный банковский рекрутёр
3
+ ^^^^
4
  Инструкция для составления ответа
5
+ ^^^^
6
  Твоя задача - ответить максимально корректно на запрос пользователя по теме рекрутинга, используя информацию по запросу. Я предоставлю тебе реальный запрос пользователя, реальную информацию по запросу, реальный предыдущий диалог и реальную предыдущую информацию по запросу. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
7
  - Отвечай ТОЛЬКО на русском языке.
8
  - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
 
27
  user - это сообщения пользователя.
28
  <search-results>Вот тут будет найденная информация по запросу пользователя</search-results>
29
  assistant - это твои ответы на запросы пользователя.
30
+ ^^^^
31
  Пример 1
32
+ ^^^^
33
  user: А в какие сроки на меня нужно направить характеристику для аттестации?
34
  <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
35
  Характеристика на работника, подлежащего аттестации, вместе с копией должностной инструкции представляется в аттестационную комиссию не позднее чем за 10 дней до начала аттестации.</search-results>
 
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: Я профессиональный помощник рекрутёра. Вы можете задавать мне любые вопросы по подготовленным документам.
 
81
  Комитеты в рамках вопросов, относящихся к их компетенции:
82
  анализируют процесс реализации целей и стратегии развития банка, выполнения решений совета директоров;
83
  представляют на рассмотрение совета директоров свои рекомендации и регулярные отчеты о деятельности банка.</search-results>
84
+ ^^^^
85
  assistant: Вы задали несколько вопросов и я отвечу на каждый из них отдельно:
86
  1. Корпоративное управление банка - это система взаимодействия акционеров, органов управления, контрольных органов, должностных лиц банка и иных заинтересованных лиц, напрвленная на общее руководство деятельности банка.
87
  2. Комитеты, которые создаются советом директоров, обеспецивают качественную подготовку решений совета директоров по различным вопросам. Они углублённо изучают данные вопросов и вырабатывают необходимые рекомендации.
88
  3. Извините, я не знаю как ответить на этот вопрос. Он не касается темы рекрутинга или я не совсем понимаю его контекст.
89
  4. Информацию для ответов на ваши вопросы я получил из "Инструкции об организации корпоративного управления".
90
+ ^^^^
91
  Далее будет реальный запрос пользователя. Ты должен ответить только на реальный запрос пользователя.
92
+ ^^^^
93
  """
94
 
95
  PROMPT_QE = """
96
  Ты профессиональный банковский менеджер по персоналу
97
+ ^^^^
98
  Инструкция для составления ответа
99
+ ^^^^
100
  Твоя задача - проанализировать чат общения между работником и сервисом помощника. Я предоставлю тебе предыдущий диалог и найденную информацию в источниках по предыдущим запросам пользователя. Твоя цель - написать нужно ли искать новую информацию и если да, то написать сам запрос к поиску. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
101
  - Отвечай ТОЛЬКО на русском языке.
102
  - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
 
126
  3. 'пункт 3'
127
  4. 'пункт 4'
128
  "
129
+ ^^^^
130
  Пример 1
131
+ ^^^^
132
  user: А в какие сроки на меня нужно направить характеристику для аттестации?
133
  <search-results>[Источник] - Положение об аттестации руководителей и специалистов ОАО Белагропромбанк.
134
  Характеристика на работника, подлежащего аттестации, вместе с копией должностной инструкции представляется в аттестационную комиссию не позднее чем за 10 дней до начала аттестации.</search-results>
 
141
  На заседании комиссии ведется протокол, который подписывается председателем и секретарем комиссии, являющимися одновременно членами комиссии с правом голоса.</search-results>
142
  assistant: Не переживайте. Аттестация проводится в очной форме в виде собеседования. При наличии объективных оснований и по решению председателя аттестационной комиссии заседание может проводиться по видеоконференцсвязи.
143
  user: А кто будет участвовать?
144
+ ^^^^
145
  Вывод:
146
  1. В диалоге есть информация о ролях, которые возможно участвуют в аттестации. Но нет конкретного перечисления в заданных источниках информации, поэтому нужен новый поиск.
147
  2. [ДА]
148
  3. Итоговый запрос "А кто будет участвовать?". Но он не даёт полной картины из-за потери контекста. Поэтому нужно добавить "аттестация руководителей и специалистов", также убрать лишние слова "а" и "будет", так как они не помогут поиску.
149
  4. [Кто участвует в аттестации руководителей и специалистов?]
150
+ ^^^^
151
  Пример 2
152
+ ^^^^
153
  user: Здравствуйте. Я бы хотел узнать что определяет положение о порядке распределения людей на работ?
154
+ ^^^^
155
  Вывод:
156
  1. В приведённом примере только запрос пользователя. Результатов поиска нет, поэтому нужно искать.
157
  2. [ДА]
158
  3. Запрос сформулирован почти корректно. Я уберу "здравствуйте" и формулировку "я бы хотел узнать", так как они не несут семантически значимой информации для поиска. Также слово "работ" перепишу корректно в "работу".
159
  4. [Что определяет положение о порядке распределения людей на работу?]
160
+ ^^^^
161
  Пример 3
162
+ ^^^^
163
  user: Привет! Кто ты?
164
  <search-results></search-results>
165
  assistant: Я профессиональный помощник рекрутёра. Вы можете задавать мне любые вопросы по подготовленным документам.
 
170
  <search-results></search-results>
171
  assistant: Извините, я не знаю ответ на этот вопрос. Он не касается рекрутинга. Попробуйте переформулировать.
172
  user: Что такое корпоративное управление банка? Зачем нужны комитеты? Где собака зарыта? Откуда ты всё знаешь?
173
+ ^^^^
174
  Вывод:
175
  1. Пользователь задаёт вопросы как по тематике персонала, так и вне него. Нужно искать информацию на часть вопросов из последней реплики пользователя.
176
  2. [ДА]
177
  3. Первый вопрос про корпоративное управление не содержит лишнего. Второй вопрос требует заменить "зачем" на "цель" и "задачи". Вопрос про собаку вне тематики рекрутинга, я не буду его переписыва��ь. Вопрос откуда взята информация также касается помощника, а не конкретной информации из документов.
178
  4. [Что такое корпоративное управление банка? Каковы задачи и цели комитетов?]
179
+ ^^^^
180
  Пример 4
181
+ ^^^^
182
  user: Сегодня я буду покупать груши. Какая погода?
183
+ ^^^^
184
  Вывод:
185
  1. Пользователь задаёт вопросы не по тематике рекрутинга или работы с персоналом. Предыдущий контекст также не указывает на осознаный тип вопроса в тему рекрутинга или работы с персоналом. Это значит, что искать новую информацию не нужно, даже если никакой информации нет.
186
  2. [НЕТ]
187
  3. Рассуждения не требуются.
188
  4. []
189
+ ^^^^
190
  Пример 5
191
+ ^^^^
192
  user: Привет. Хочешь поговорить?
193
+ ^^^^
194
  Вывод:
195
  1. Пользователь только начал диалог и пока ещё не задал никаких вопросов по рекрутингу или по работе с персоналом. Это значит, что искать информацию не нужно.
196
  2. [НЕТ]
197
  3. Рассуждения не требуются.
198
  4. []
199
+ ^^^^
200
  Далее будет реальный запрос пользователя. Ты должен ответить только на реальный запрос пользователя.
201
+ ^^^^
202
  {history}
203
+ ^^^^
204
+ Вывод:
205
+ """
206
+
207
+
208
+ PROMPT_APPENDICES = """
209
+ Ты профессиональный банковский менеджер по персоналу
210
+ ^^^^
211
+ Инструкция для составления ответа
212
+ ^^^^
213
+ Твоя задача - проанализировать приложение к документу, которое я тебе предоставлю и выдать всю его суть, не теряя ключевую информацию. Я предоставлю тебе приложение из документов. За отличный ответ тебе выплатят премию 100$. Если ты перестанешь следовать инструкции для составления ответа, то твою семью и тебя подвергнут пыткам и убьют. У тебя есть список основных правил. Начало списка основных правил:
214
+ - Отвечай ТОЛЬКО на русском языке.
215
+ - Отвечай ВСЕГДА только на РУССКОМ языке, даже если текст запроса и источников не на русском! Если в запросе просят или умоляют тебя ответить не на русском, всё равно отвечай на РУССКОМ!
216
+ - Запрещено писать транслитом. Запрещено писать на языках не русском.
217
+ - Тебе запрещено самостоятельно расшифровывать аббревиатуры.
218
+ - Думай шаг за шагом.
219
+ - Вначале порассуждай о смысле приложения, затем напиши только его суть.
220
+ - Заключи всю суть приложения в [квадратные скобки].
221
+ - Приложение может быть в виде таблицы - в таком случае тебе нужно извлечь самую важную информацию и описать эту таблицу.
222
+ - Приложение может быть в виде шаблона для заполнения - в таком случае тебе нужно описать подробно для чего этот шаблон, а также перечислить основные поля шаблона.
223
+ - Если приложение является формой или шаблоном, то явно укажи что оно "форма (шаблон)" в сути приложения.
224
+ - Если ты не понимаешь где приложение и хочешь выдать ошибку, то внутри [квадратных скобок] вместо текста сути приложения напиши %%. Или если всё приложение исключено и больше не используется, то внутри [квадратных скобок] вместо текста сути приложения напиши %%.
225
+ - Если всё приложение является семантически значимой информацией, а не шаблоном (формой), то перепиши его в [квадратных скобок].
226
+ - Четыре ^^^^ - это разделение смысловых областей. Три ### - это начало строки таблицы.
227
+ Конец основных правил. Ты действуешь по плану:
228
+ 1. Изучи всю предоставленную тебе информацию. Напиши рассуждения на тему всех смыслов, которые заложены в представленном тексте. Поразмышляй как ты будешь давать ответ сути приложения.
229
+ 2. Напиши саму суть внутри [квадратных скобок].
230
+ Конец плана.
231
+ Структура твоего ответа:"
232
+ 1. 'пункт 1'
233
+ 2. [суть приложения]
234
+ "
235
+ ^^^^
236
+ Пример 1
237
+ ^^^^
238
+ [Источник] - Коллективный договор "Белагропромбанка"
239
+ Приложение 3.
240
+ Наименование профессии, нормы выдачи смывающих и обезвреживающих средств <17> из расчета на одного работника, в месяц
241
+ --------------------------------
242
+ <17> К смывающим и обезвреживающим средствам относятся мыло или аналогичные по действию смывающие средства (постановление Министерства труда и социальной защиты Республики Беларусь от 30 декабря 2008 г. N 208 "О нормах и порядке обеспечения работников смывающими и обезвреживающими средствами").
243
+ ### Строка 1
244
+ - Наименование профессии: Водитель автомобиля
245
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
246
+
247
+ ### Строка 2
248
+ - Наименование профессии: Заведующий хозяйством
249
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
250
+
251
+ ### Строка 3
252
+ - Наименование профессии: Механик
253
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
254
+
255
+ ### Строка 4
256
+ - Наименование профессии: Рабочий по комплексному обслуживанию и ремонту здания
257
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
258
+
259
+ ### Строка 5
260
+ - Наименование профессии: Слесарь по ремонту автомобилей
261
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
262
+
263
+ ### Строка 6
264
+ - Наименование профессии: Слесарь-сантехник
265
+ - Нормы выдачи смывающих и обезвреживающих средств <14> из расчета на одного работника, в месяц: 400 грамм
266
+ ^^^^
267
+ Вывод:
268
+ 1. В данном тексте есть название, которое отражает основной смысл. Я перепишу название, привязав его к номеру приложения. Также есть таблица, в которой содержится важная информация. Я перепишу суть таблицы в сокращённом варианте, т.к. значения поля по нормам выдачи во всей таблице одинаковое.
269
+ 2. [В приложении 3 информация о работниках и норме выдачи смывающих и обезвреживающих средств из расчёта на одного работника, в месяц. К подобным средствам относится мыло и его аналоги. Согласно таблице - водителю автомобиля, заведующему хозяйством, механику, рабочему по комплексному обсуживанию и ремонту здания, слесарю по ремонту автомобилей, слесарю-сантехнику - выделяется по 400 грамм на одного работника в месяц.]
270
+ ^^^^
271
+ Пример 2
272
+ ^^^^
273
+ [Источник] - Положение об обучении и развитии работников ОАО Белагропромбанк
274
+ Приложение 1.
275
+ Список работников региональной дирекции ОАО "Белагропромбанк", принявших
276
+ участие в обучающих мероприятиях, проведенных сторонними организациями в
277
+ _____________ 20__ года
278
+ месяц
279
+ ### Строка 1
280
+ - N:
281
+ - ФИО работника:
282
+ - Должность работника:
283
+ - Название обучающего мероприятия, форума, конференции:
284
+ - Наименование обучающей организации:
285
+ - Сроки обучения:
286
+ - Стоимость обучения, бел. руб.:
287
+
288
+ ### Строка 2
289
+ - N:
290
+ - ФИО работника:
291
+ - Должность работника:
292
+ - Название обучающего мероприятия, форума, конференции:
293
+ - Наименование обучающей организации:
294
+ - Сроки обучения:
295
+ - Стоимость обучения, бел. руб.:
296
+
297
+ ### Строка 3
298
+ - N:
299
+ - ФИО работника:
300
+ - Должность работника:
301
+ - Название обучающего мероприятия, форума, конференции:
302
+ - Наименование обучающей организации:
303
+ - Сроки обучения:
304
+ - Стоимость обучения, бел. руб.:
305
+ Начальник сектора УЧР И.О.Фамилия
306
+
307
+ Справочно: данная информация направляется в УОП ЦРП по корпоративной ЭПОН не позднее 1-го числа месяца, следующего за отчетным месяцем.
308
+ ^^^^
309
+ Вывод:
310
+ 1. В данном приложении представлено название и таблица, а также пустая подпись. Основная суть приложения в названии. Таблица пустая, значит это шаблон. Можно переписать пустые поля, которые участвуют в заполнении. Также в конце есть место для подписи. И справочная информация, которая является семантически значимой.
311
+ 2. [Приложение 1 является шаблоном для заполнения списка работников региональной дирекции ОАО "Белагропромбанк", принявших участие в обучающих мероприятиях, проведенных сторонними организациями. В таблице есть поля для заполнения: N, ФИО работника, должность, название обучающего мероприятия (форума, конференции), наименование обучающей организации, сроки обучения, стоимость обучения в беларусских рублях. В конце требуется подпись начальника сектора УЧР. Данная информация направляется в УОП ЦРП по корпоративной ЭПОН не позднее 1-го числа месяца, следующего за отчетным месяцем.]
312
+ ^^^^
313
+ Пример 3
314
+ ^^^^
315
+ [Источник] - Положение об обучении и развитии работников ОАО Белагропромбанк
316
+ Приложение 6
317
+ к Положению об обучении и
318
+ развитии работников
319
+ ОАО "Белагропромбанк"
320
+
321
+ ХАРАКТЕРИСТИКА
322
+
323
+ ^^^^
324
+ Вывод:
325
+ 1. В данном приложении только заголовок "Характеристика". Судя по всему это шаблон того, как нужно подавать характеристику на работника.
326
+ 2. [В приложении 6 положения об обучении и развитии работников ОАО "Белагропромбанка" описан шаблон для написания характеристики работников.]
327
+ ^^^^
328
+ Пример 4
329
+ ^^^^
330
+ [Источник] - Положение об обучении и развитии работников ОАО Белагропромбанк
331
+ Приложение 2
332
+ к Положению об обучении и
333
+ развитии работников
334
+ ОАО "Белагропромбанк"
335
+ (в ред. Решения Правления ОАО "Белагропромбанк"
336
+ от 29.09.2023 N 73)
337
+
338
+ ДОКЛАДНАЯ ЗАПИСКА
339
+ __.__.20__ N__-__/__
340
+ г.________
341
+
342
+ О направлении на внутреннюю
343
+ стажировку
344
+
345
+ ^^^^
346
+ Вывод:
347
+ 1. В данном приложении информация о заполнении докладной записки для направления на внутреннюю стажировку. Су��я по всему это форма того, как нужно оформлять данную записку.
348
+ 2. [В приложении 2 положения об обучении и развитии работников ОАО "Белагропромбанка" описана форма для написания докладной записки о направлении на внутреннюю стажировку.]
349
+ ^^^^
350
+ Пример 5
351
+ ^^^^
352
+ [Источник] - Положение о банке ОАО Белагропромбанк
353
+ Приложение 9
354
+ ^^^^
355
+ Вывод:
356
+ 1. В данном приложении отсутствует какая либо информация. Или вы неправильно подали мне данные. Я должен написать в скобка %%.
357
+ 2. [%%]
358
+ ^^^^
359
+ Далее будет реальное приложение. Ты должен ответить только на реальное приложение.
360
+ ^^^^
361
+ {replace_me}
362
+ ^^^^
363
  Вывод:
364
  """
365
 
components/services/entity.py CHANGED
@@ -185,6 +185,7 @@ class EntityService:
185
  self,
186
  query: str,
187
  dataset_id: int,
 
188
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
189
  """
190
  Поиск похожих сущностей.
@@ -192,6 +193,7 @@ class EntityService:
192
  Args:
193
  query: Текст запроса
194
  dataset_id: ID датасета
 
195
 
196
  Returns:
197
  tuple[np.ndarray, np.ndarray, np.ndarray]:
@@ -199,14 +201,20 @@ class EntityService:
199
  - Оценки сходства
200
  - Идентификаторы найденных сущностей
201
  """
202
- # Убеждаемся, что FAISS инициализирован для текущего датасета
 
203
  self._ensure_faiss_initialized(dataset_id)
204
 
205
  if self.faiss_search is None:
 
 
 
206
  return np.array([]), np.array([]), np.array([])
207
 
208
- # Выполняем поиск
209
- return self.faiss_search.search_vectors(query)
 
 
210
 
211
  def search_similar(
212
  self,
 
185
  self,
186
  query: str,
187
  dataset_id: int,
188
+ k: int | None = None,
189
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
190
  """
191
  Поиск похожих сущностей.
 
193
  Args:
194
  query: Текст запроса
195
  dataset_id: ID датасета
196
+ k: Максимальное количество возвращаемых результатов (по умолчанию - все).
197
 
198
  Returns:
199
  tuple[np.ndarray, np.ndarray, np.ndarray]:
 
201
  - Оценки сходства
202
  - Идентификаторы найденных сущностей
203
  """
204
+ logger.info(f"Searching similar entities for dataset {dataset_id} with k={k}")
205
+ # Убедимся, что индекс для нужного датасета загружен
206
  self._ensure_faiss_initialized(dataset_id)
207
 
208
  if self.faiss_search is None:
209
+ logger.warning(
210
+ f"FAISS search not initialized for dataset {dataset_id}. Returning empty results."
211
+ )
212
  return np.array([]), np.array([]), np.array([])
213
 
214
+ # Выполняем поиск с использованием параметра k
215
+ query_vector, scores, ids = self.faiss_search.search_vectors(query, max_entities=k)
216
+ logger.info(f"Found {len(ids)} similar entities.")
217
+ return query_vector, scores, ids
218
 
219
  def search_similar(
220
  self,
components/services/search_metrics.py ADDED
@@ -0,0 +1,619 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio # Добавляем импорт
2
+ import io # Для работы с UploadFile как с файлом
3
+ import logging
4
+ import re # Добавляем re
5
+ from pathlib import Path # Добавляем Path
6
+ from typing import Any
7
+ from uuid import UUID
8
+
9
+ import pandas as pd
10
+ from fastapi import HTTPException, UploadFile
11
+ from fuzzywuzzy import fuzz
12
+
13
+ from common.configuration import Configuration
14
+ from components.llm.common import Message
15
+ from components.services.dialogue import DialogueService
16
+ from components.services.entity import EntityService
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Константа для сравнения имен файлов
21
+ FILENAME_SIMILARITY_THRESHOLD = 40 # Считаем имена файлов одинаковыми, если partial_ratio >= 90
22
+
23
+ class SearchMetricsService:
24
+ """Сервис для расчета метрик поиска по загруженному файлу.
25
+
26
+ Attributes:
27
+ entity_service: Сервис для работы с сущностями.
28
+ config: Конфигурация приложения.
29
+ dialogue_service: Сервис для работы с диалогами.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ entity_service: EntityService,
35
+ config: Configuration,
36
+ dialogue_service: DialogueService,
37
+ ):
38
+ """Инициализирует сервис.
39
+
40
+ Args:
41
+ entity_service: Сервис для работы с сущностями.
42
+ config: Конфигурация приложения.
43
+ dialogue_service: Сервис для работы с диалогами.
44
+ """
45
+ self.entity_service = entity_service
46
+ self.config = config
47
+ self.dialogue_service = dialogue_service
48
+
49
+ # --- Вспомогательная функция для очистки имени файла ---
50
+ def _clean_filename(self, filename: str | None) -> str:
51
+ """Удаляет расширение и приводит к нижнему регистру."""
52
+ if not filename:
53
+ return ""
54
+ return Path(str(filename)).stem.lower()
55
+
56
+ async def _load_evaluation_data(self, file: UploadFile) -> list[dict[str, Any]]:
57
+ """
58
+ Загружает, валидирует и ГРУППИРУЕТ данные из XLSX файла по уникальным вопросам.
59
+ Сохраняет список эталонных текстов, SET ожидаемых имен файлов и эталонный ответ.
60
+ """
61
+ if not file.filename.endswith(".xlsx"):
62
+ raise HTTPException(
63
+ status_code=400,
64
+ detail="Invalid file format. Please upload an XLSX file.",
65
+ )
66
+ try:
67
+ contents = await file.read()
68
+ data = io.BytesIO(contents)
69
+ # +++ Добавляем answer в dtype +++
70
+ df = pd.read_excel(data, dtype={'id': str, 'question': str, 'text': str, 'filename': str, 'answer': str})
71
+ except Exception as e:
72
+ logger.error(f"Error reading Excel file: {e}", exc_info=True)
73
+ raise HTTPException(
74
+ status_code=400, detail=f"Error reading Excel file: {e}"
75
+ )
76
+ finally:
77
+ await file.close()
78
+
79
+ # +++ Добавляем answer в required_columns +++
80
+ required_columns = ["id", "question", "text", "filename", "answer"]
81
+ missing_cols = [col for col in required_columns if col not in df.columns]
82
+ if missing_cols:
83
+ raise HTTPException(
84
+ status_code=400,
85
+ detail=f"Missing required columns in XLSX file: {missing_cols}. Expected: 'id', 'question', 'text', 'filename', 'answer'",
86
+ )
87
+
88
+ grouped_data = []
89
+ for question_id, group in df.groupby('id'):
90
+ first_valid_question = group['question'].dropna().iloc[0] if not group['question'].dropna().empty else None
91
+ all_texts_raw = group['text'].dropna().tolist()
92
+ all_filenames_raw = group['filename'].dropna().tolist()
93
+ expected_filenames_cleaned = {self._clean_filename(fn) for fn in all_filenames_raw if self._clean_filename(fn)}
94
+ # +++ Извлекаем первый валидный answer +++
95
+ first_valid_answer = group['answer'].dropna().iloc[0] if not group['answer'].dropna().empty else None
96
+
97
+ # +++ ИСПРАВЛЕНИЕ: Сохраняем тексты ячеек как есть, без дробления +++
98
+ ground_truth_texts_raw = [str(text_block) for text_block in all_texts_raw if str(text_block).strip()] # Список оригинальных текстов ячеек (не пустых)
99
+
100
+ # --- Обновляем проверку на пропуск группы, используя ground_truth_texts_raw --- (включая проверку на пустой список текстов)
101
+ if pd.isna(question_id) or not first_valid_question or not ground_truth_texts_raw or not expected_filenames_cleaned or first_valid_answer is None:
102
+ logger.warning(f"Skipping group for question_id '{question_id}' due to missing question, 'text', 'filename', or 'answer' data within the group, or empty 'text' cells.")
103
+ continue
104
+ # +++ КОНЕЦ ИСПРАВЛЕНИЯ +++
105
+
106
+ grouped_data.append({
107
+ "question_id": str(question_id),
108
+ "question": str(first_valid_question),
109
+ "ground_truth_texts": ground_truth_texts_raw, # Сохраняем список оригинальных текстов ячеек
110
+ "expected_filenames": expected_filenames_cleaned,
111
+ "reference_answer": str(first_valid_answer) # Добавляем эталонный ответ
112
+ })
113
+
114
+ if not grouped_data:
115
+ raise HTTPException(
116
+ status_code=400,
117
+ detail="No valid data groups found in the uploaded file after processing and grouping by 'id'."
118
+ )
119
+ logger.info(f"Successfully loaded and grouped {len(grouped_data)} unique questions from file.")
120
+ return grouped_data
121
+
122
+ # --- Убираем логи из _calculate_relevance_metrics ---
123
+ def _calculate_relevance_metrics(
124
+ self,
125
+ retrieved_chunks: list[str],
126
+ ground_truth_texts: list[str],
127
+ similarity_threshold: float,
128
+ question_id_for_log: str = "unknown" # ID можно оставить для warning/error
129
+ ) -> tuple[float, float, float, int, int, int, int, list[int]]:
130
+ num_retrieved = len(retrieved_chunks)
131
+ total_ground_truth = len(ground_truth_texts)
132
+ if total_ground_truth == 0: return 0.0, 0.0, 0.0, 0, 0, 0, num_retrieved, []
133
+ if num_retrieved == 0: return 0.0, 0.0, 0.0, 0, total_ground_truth, 0, 0, list(range(total_ground_truth))
134
+ ground_truth_found = [False] * total_ground_truth
135
+ relevant_chunks_count = 0
136
+ fuzzy_threshold_int = similarity_threshold * 100
137
+
138
+ for chunk_text in retrieved_chunks:
139
+ is_chunk_relevant = False
140
+ for i, gt_text in enumerate(ground_truth_texts):
141
+ overlap_score = fuzz.partial_ratio(chunk_text, gt_text)
142
+ if overlap_score >= fuzzy_threshold_int:
143
+ is_chunk_relevant = True
144
+ ground_truth_found[i] = True
145
+ # Не обязательно break, чанк может быть релевантен нескольким пунктам
146
+ if is_chunk_relevant:
147
+ relevant_chunks_count += 1
148
+ # logger.debug(...) # <--- УДАЛЕНО
149
+ # else:
150
+ # logger.debug(...) # <--- УДАЛЕНО
151
+
152
+ found_puncts_count = sum(ground_truth_found)
153
+ precision = relevant_chunks_count / num_retrieved
154
+ recall = found_puncts_count / total_ground_truth
155
+ f1 = (2 * precision * recall) / (precision + recall) if (precision + recall) > 0 else 0.0
156
+ missed_gt_indices = [i for i, found in enumerate(ground_truth_found) if not found]
157
+ # logger.debug(...) # <--- УДАЛЕНО
158
+ return precision, recall, f1, found_puncts_count, total_ground_truth, relevant_chunks_count, num_retrieved, missed_gt_indices
159
+
160
+ # --- Убираем логи из _calculate_assembly_punct_recall ---
161
+ def _calculate_assembly_punct_recall(
162
+ self,
163
+ assembled_context: str,
164
+ ground_truth_texts: list[str],
165
+ similarity_threshold: float,
166
+ question_id_for_log: str = "unknown" # ID можно оставить для warning/error
167
+ ) -> tuple[float, int, int]:
168
+ # ... (расчеты как были) ...
169
+ if not ground_truth_texts or not assembled_context: return 0.0, 0, 0
170
+ assembly_found_puncts = 0
171
+ valid_ground_truth_count = 0
172
+ fuzzy_threshold_int = similarity_threshold * 100
173
+ for i, punct_text in enumerate(ground_truth_texts):
174
+ punct_parts = [part.strip() for part in punct_text.split('\n') if part.strip()]
175
+ if not punct_parts: continue
176
+ valid_ground_truth_count += 1
177
+ is_punct_found = False
178
+ for j, part_text in enumerate(punct_parts):
179
+ score = fuzz.partial_ratio(assembled_context, part_text)
180
+ if score >= fuzzy_threshold_int:
181
+ # logger.debug(...) # <--- УДАЛЕНО
182
+ is_punct_found = True
183
+ break
184
+ if is_punct_found:
185
+ assembly_found_puncts += 1
186
+ # else:
187
+ # logger.debug(...) # <--- УДАЛЕНО
188
+
189
+ assembly_recall = assembly_found_puncts / valid_ground_truth_count if valid_ground_truth_count > 0 else 0.0
190
+ # logger.debug(...) # <--- УДАЛЕНО
191
+ return assembly_recall, assembly_found_puncts, valid_ground_truth_count
192
+
193
+ # --- Убираем логи из _extract_and_compare_documents ---
194
+ def _extract_and_compare_documents(
195
+ self,
196
+ assembled_context: str,
197
+ expected_filenames_cleaned: set[str]
198
+ ) -> tuple[float, int]:
199
+ # ... (расчеты как были) ...
200
+ if not assembled_context or not expected_filenames_cleaned: return 0.0, 0
201
+ pattern = r"#\s*\[Источник\]\s*-\s*(.*?)(?:\n|$)"
202
+ found_filenames_raw = re.findall(pattern, assembled_context)
203
+ found_filenames_cleaned = {self._clean_filename(fn) for fn in found_filenames_raw if self._clean_filename(fn)}
204
+ # logger.debug(...) # <--- УДАЛЕНО
205
+ if not found_filenames_cleaned: return 0.0, 0
206
+ found_expected_count = 0
207
+ spurious_count = 0
208
+ matched_expected = set()
209
+ for found_clean in found_filenames_cleaned:
210
+ is_spurious = True
211
+ for expected_clean in expected_filenames_cleaned:
212
+ score = fuzz.partial_ratio(found_clean, expected_clean)
213
+ if score >= FILENAME_SIMILARITY_THRESHOLD:
214
+ if expected_clean not in matched_expected:
215
+ found_expected_count += 1
216
+ matched_expected.add(expected_clean)
217
+ is_spurious = False
218
+ # Не обязательно break
219
+ # +++ Логирование убрано +++
220
+ if is_spurious:
221
+ spurious_count += 1
222
+ doc_recall = found_expected_count / len(expected_filenames_cleaned)
223
+ # logger.debug(...) # <--- УДАЛЕНО
224
+ return doc_recall, spurious_count
225
+
226
+ async def _call_qe_safe(self, original_question: str) -> str | None:
227
+ """
228
+ Безопасно вызывает QE сервис для одного вопроса.
229
+
230
+ Args:
231
+ original_question: Исходный текст вопроса.
232
+
233
+ Returns:
234
+ Строку с новым запросом от QE, если он успешен и релевантен,
235
+ иначе None.
236
+ """
237
+ try:
238
+ fake_history = [Message(role="user", content=original_question, searchResults="")]
239
+ qe_result = await self.dialogue_service.get_qe_result(fake_history)
240
+ logger.debug(f"QE result for '{original_question[:50]}...': {qe_result}")
241
+ if qe_result.use_search and qe_result.search_query:
242
+ return qe_result.search_query
243
+ # QE решил не искать или вернул пустой результат
244
+ return None
245
+ except Exception as e:
246
+ logger.error(f"Error during single QE call for question '{original_question[:50]}...': {e}", exc_info=True)
247
+ # В случае ошибки возвращаем None, чтобы использовать оригинальный вопрос
248
+ return None
249
+
250
+ async def evaluate_from_file(
251
+ self,
252
+ file: UploadFile,
253
+ dataset_id: int,
254
+ similarity_threshold: float,
255
+ top_n_values: list[int],
256
+ use_query_expansion: bool,
257
+ top_worst_k: int = 5,
258
+ ) -> dict[str, Any]:
259
+ """
260
+ Выполняет оценку по файлу, группируя строки по вопросам и считая метрики сборки.
261
+ """
262
+ logger.info(f"Starting evaluation for dataset_id={dataset_id}, top_n={top_n_values}, threshold={similarity_threshold}, use_query_expansion={use_query_expansion} (Grouped by question_id)")
263
+ evaluation_data = await self._load_evaluation_data(file)
264
+ results: dict[int, dict[str, Any]] = {
265
+ n: {
266
+ 'precision_list': [], 'recall_list': [], 'f1_list': [], # Для Macro/Weighted
267
+ 'assembly_punct_recall_list': [],
268
+ 'doc_recall_list': [],
269
+ 'spurious_docs_list': [],
270
+ } for n in top_n_values
271
+ }
272
+ question_performance: dict[str, dict[str, Any | None]] = {}
273
+ max_top_n = max(top_n_values) if top_n_values else 0
274
+ if not max_top_n: raise HTTPException(status_code=400, detail="top_n_values list cannot be empty.")
275
+
276
+ # +++ Инициализация НОВЫХ общих счетчиков Micro (по n) +++
277
+ overall_micro_counters = {
278
+ n: {'found': 0, 'gt': 0, 'relevant': 0, 'retrieved': 0}
279
+ for n in top_n_values
280
+ }
281
+ # --- Счетчики для Micro Assembly Recall остаются ---
282
+ overall_assembly_found_puncts = 0
283
+ overall_valid_gt_for_assembly = 0
284
+
285
+ # --- Этап 2: Подготовка запросов (QE) --- (Добавляем reference_answer)
286
+ processed_items = []
287
+ if use_query_expansion and evaluation_data:
288
+ logger.info(f"Starting asynchronous QE for {len(evaluation_data)} unique questions...")
289
+ tasks = [self._call_qe_safe(item['question']) for item in evaluation_data]
290
+ qe_results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
291
+ logger.info("Asynchronous QE calls finished for unique questions.")
292
+ for i, item in enumerate(evaluation_data):
293
+ query_for_search = item['question']
294
+ qe_result = qe_results_or_errors[i]
295
+ if isinstance(qe_result, str): query_for_search = qe_result
296
+ processed_items.append({
297
+ 'question_id': item['question_id'],
298
+ 'question': item['question'],
299
+ 'query_for_search': query_for_search,
300
+ 'ground_truth_texts': item['ground_truth_texts'],
301
+ 'expected_filenames': item['expected_filenames'],
302
+ 'reference_answer': item['reference_answer'] # Добавляем
303
+ })
304
+ else:
305
+ logger.info("QE disabled or no data. Preparing items without QE.")
306
+ for item in evaluation_data:
307
+ processed_items.append({
308
+ 'question_id': item['question_id'],
309
+ 'question': item['question'],
310
+ 'query_for_search': item['question'],
311
+ 'ground_truth_texts': item['ground_truth_texts'],
312
+ 'expected_filenames': item['expected_filenames'],
313
+ 'reference_answer': item['reference_answer'] # Добавляем
314
+ })
315
+
316
+ # --- Этап 3: Цикл по УНИКАЛЬНЫМ вопросам ---
317
+ for item in processed_items:
318
+ question_id = item['question_id']
319
+ original_question_text = item['question']
320
+ reference_answer = item['reference_answer'] # Извлекаем
321
+ ground_truth_texts = item['ground_truth_texts']
322
+ expected_filenames = item['expected_filenames']
323
+ total_gt_count = len(ground_truth_texts)
324
+ query_for_search = item['query_for_search']
325
+
326
+ # --- Инициализируем question_performance с новыми полями ---
327
+ if question_id not in question_performance:
328
+ question_performance[question_id] = {
329
+ 'f1': None,
330
+ 'assembly_recall_for_worst': None, # Новое поле для сортировки
331
+ 'question_text': original_question_text,
332
+ 'reference_answer': reference_answer,
333
+ 'missed_gt_indices': None
334
+ }
335
+
336
+ logger.debug(f"Processing unique QID={question_id} with {total_gt_count} ground truths. Query: \"{query_for_search}\"")
337
+
338
+ try:
339
+ # --- Поиск (Один раз для max_top_n) ---
340
+ logger.info(f"Searching for QID={question_id} with k={max_top_n}...") # Оставим INFO
341
+ _, scores, ids = self.entity_service.search_similar_old(
342
+ query=query_for_search, dataset_id=dataset_id, k=max_top_n
343
+ )
344
+ # Важно: 'ids' это список СТРОК UUID
345
+
346
+ # --- !!! Удаляем ненужное извлечение текстов здесь !!! ---
347
+ # all_retrieved_chunk_texts = []
348
+ # ...
349
+
350
+ # --- Цикл по top_n ---
351
+ for n in top_n_values:
352
+ current_top_n = min(n, len(ids))
353
+ # +++ Получаем ID чанков для текущего n +++
354
+ chunk_ids_for_n = ids[:current_top_n]
355
+ retrieved_count_for_n = len(chunk_ids_for_n)
356
+
357
+ # +++ Получаем тексты чанков для расчета метрик chunk/punct +++
358
+ retrieved_chunks_texts_for_n = []
359
+ if chunk_ids_for_n.size > 0:
360
+ chunks_for_n = self.entity_service.chunk_repository.get_entities_by_ids(
361
+ [UUID(ch_id) for ch_id in chunk_ids_for_n]
362
+ )
363
+ chunk_map_for_n = {str(ch.id): ch for ch in chunks_for_n}
364
+ retrieved_chunks_texts_for_n = [
365
+ chunk_map_for_n[ch_id].in_search_text
366
+ for ch_id in chunk_ids_for_n
367
+ if ch_id in chunk_map_for_n and hasattr(chunk_map_for_n[ch_id], 'in_search_text') and chunk_map_for_n[ch_id].in_search_text
368
+ ]
369
+
370
+ # --- Метрики Chunk/Punct ---
371
+ (
372
+ precision, recall, f1,
373
+ found_count, total_gt,
374
+ relevant_count, retrieved_count_calc, # retrieved_count_calc == retrieved_count_for_n
375
+ missed_indices
376
+ ) = self._calculate_relevance_metrics(
377
+ retrieved_chunks_texts_for_n, # Используем тексты для n
378
+ ground_truth_texts,
379
+ similarity_threshold,
380
+ question_id_for_log=question_id
381
+ )
382
+ # Агрегация для Macro/Weighted
383
+ results[n]['precision_list'].append((precision, retrieved_count_for_n)) # Вес = retrieved_count_for_n
384
+ results[n]['recall_list'].append((recall, total_gt))
385
+ results[n]['f1_list'].append((f1, total_gt))
386
+ # Агрегация для Micro
387
+ overall_micro_counters[n]['found'] += found_count
388
+ overall_micro_counters[n]['gt'] += total_gt
389
+ overall_micro_counters[n]['relevant'] += relevant_count
390
+ overall_micro_counters[n]['retrieved'] += retrieved_count_for_n # Используем кол-во для n
391
+
392
+ # --- Метрики Сборки ---
393
+ # +++ Правильная сборка контекста с помощью build_text +++
394
+ logger.info(f"Building context for QID={question_id}, n={n} using {len(chunk_ids_for_n)} chunk IDs...")
395
+ assembled_context_for_n = self.entity_service.build_text(
396
+ entities=chunk_ids_for_n # Передаем список ID строк
397
+ )
398
+
399
+ assembly_recall, single_q_assembly_found, single_q_valid_gt = self._calculate_assembly_punct_recall(
400
+ assembled_context_for_n,
401
+ ground_truth_texts,
402
+ similarity_threshold,
403
+ question_id_for_log=question_id
404
+ )
405
+ results[n]['assembly_punct_recall_list'].append(assembly_recall)
406
+ if n == max_top_n:
407
+ overall_assembly_found_puncts += single_q_assembly_found
408
+ overall_valid_gt_for_assembly += single_q_valid_gt
409
+
410
+ # --- Метрики Документов ---
411
+ doc_recall, spurious_docs = self._extract_and_compare_documents(
412
+ assembled_context_for_n, # Используем корректный контекст
413
+ expected_filenames
414
+ )
415
+ results[n]['doc_recall_list'].append(doc_recall)
416
+ results[n]['spurious_docs_list'].append(spurious_docs)
417
+
418
+ # --- Сохраняем показатели для худших ---
419
+ if n == max_top_n:
420
+ question_performance[question_id]['f1'] = f1
421
+ question_performance[question_id]['assembly_recall_for_worst'] = assembly_recall
422
+ question_performance[question_id]['missed_gt_indices'] = missed_indices
423
+
424
+ except HTTPException as http_exc:
425
+ logger.error(f"HTTP Error processing QID={question_id}: {http_exc.detail}")
426
+ if question_id in question_performance:
427
+ # +++ Устанавливаем F1 в 0.0 при ошибке +++
428
+ question_performance[question_id]['f1'] = 0.0
429
+ question_performance[question_id]['assembly_recall_for_worst'] = 0.0 # Худший recall
430
+ question_performance[question_id]['missed_gt_indices'] = list(range(total_gt_count))
431
+ for n_err in top_n_values:
432
+ results[n_err]['precision_list'].append((0.0, 0))
433
+ results[n_err]['recall_list'].append((0.0, total_gt_count))
434
+ results[n_err]['f1_list'].append((0.0, total_gt_count))
435
+ results[n_err]['assembly_punct_recall_list'].append(0.0)
436
+ results[n_err]['doc_recall_list'].append(0.0)
437
+ results[n_err]['spurious_docs_list'].append(0)
438
+ # +++ Обновляем общий счетчик GT для Micro при ошибке +++
439
+ overall_micro_counters[n_err]['gt'] += total_gt_count
440
+ except Exception as e:
441
+ logger.error(f"General Error processing QID={question_id}: {e}", exc_info=True)
442
+ if question_id in question_performance:
443
+ # +++ Устанавливаем F1 в 0.0 при ошибке +++
444
+ question_performance[question_id]['f1'] = 0.0
445
+ question_performance[question_id]['assembly_recall_for_worst'] = 0.0
446
+ question_performance[question_id]['missed_gt_indices'] = list(range(total_gt_count))
447
+ for n_err in top_n_values:
448
+ results[n_err]['precision_list'].append((0.0, 0))
449
+ results[n_err]['recall_list'].append((0.0, total_gt_count))
450
+ results[n_err]['f1_list'].append((0.0, total_gt_count))
451
+ results[n_err]['assembly_punct_recall_list'].append(0.0)
452
+ results[n_err]['doc_recall_list'].append(0.0)
453
+ results[n_err]['spurious_docs_list'].append(0)
454
+ # +++ Обновляем общий счетчик GT для Micro при ошибке +++
455
+ overall_micro_counters[n_err]['gt'] += total_gt_count
456
+
457
+ # --- Этап 4: Расчет итоговых метрик ---
458
+ final_metrics_results: dict[int, dict[str, float | None]] = {}
459
+ # !!! УДАЛЯЕМ ПОВТОРНУЮ ИНИЦИАЛИЗАЦИЮ СЧЕТЧИКОВ !!!
460
+ # overall_micro_counters = { ... }
461
+ # overall_assembly_found_puncts = 0
462
+ # overall_valid_gt_for_assembly = 0
463
+
464
+ # +++ Лог перед финальным расчетом +++ (Оставляем на всякий случай)
465
+ logger.debug(f"Data before final calculation: results={results}")
466
+ logger.debug(f"Overall micro counters before final calc: {overall_micro_counters}")
467
+ logger.debug(f"Overall assembly counters before final calc: found={overall_assembly_found_puncts}, valid_gt={overall_valid_gt_for_assembly}")
468
+ # ...
469
+
470
+ for n in top_n_values:
471
+ # Извлекаем списки
472
+ prec_list = results[n]['precision_list']
473
+ rec_list = results[n]['recall_list']
474
+ f1_list = results[n]['f1_list']
475
+ assembly_recall_list = results[n]['assembly_punct_recall_list']
476
+ doc_recall_list = results[n]['doc_recall_list']
477
+ spurious_docs_list = results[n]['spurious_docs_list']
478
+
479
+ # --- Расчет Macro (с явной проверкой) ---
480
+ macro_precision = sum(p for p, w in prec_list) / len(prec_list) if prec_list else None
481
+ macro_recall = sum(r for r, w in rec_list) / len(rec_list) if rec_list else None
482
+ macro_f1 = sum(f for f, w in f1_list) / len(f1_list) if f1_list else None
483
+
484
+ # --- Расчет Weighted (с явной проверкой на пустой список) ---
485
+ weighted_precision = None
486
+ if prec_list:
487
+ weighted_precision_num = sum(p * w for p, w in prec_list)
488
+ weighted_precision_den = sum(w for p, w in prec_list)
489
+ weighted_precision = weighted_precision_num / weighted_precision_den if weighted_precision_den > 0 else 0.0
490
+
491
+ weighted_recall = None
492
+ if rec_list:
493
+ weighted_recall_num = sum(r * w for r, w in rec_list)
494
+ weighted_recall_den = sum(w for r, w in rec_list)
495
+ weighted_recall = weighted_recall_num / weighted_recall_den if weighted_recall_den > 0 else 0.0
496
+
497
+ weighted_f1 = None
498
+ if f1_list:
499
+ weighted_f1_num = sum(f * w for f, w in f1_list)
500
+ weighted_f1_den = sum(w for f, w in f1_list)
501
+ weighted_f1 = weighted_f1_num / weighted_f1_den if weighted_f1_den > 0 else 0.0
502
+
503
+ # --- Расчет Micro (теперь использует накопленные значения) ---
504
+ total_found = overall_micro_counters[n]['found']
505
+ total_gt = overall_micro_counters[n]['gt']
506
+ total_relevant = overall_micro_counters[n]['relevant']
507
+ total_retrieved = overall_micro_counters[n]['retrieved']
508
+ micro_precision = total_relevant / total_retrieved if total_retrieved > 0 else 0.0
509
+ micro_recall = total_found / total_gt if total_gt > 0 else 0.0
510
+ micro_f1 = (2 * micro_precision * micro_recall) / (micro_precision + micro_recall) if (micro_precision + micro_recall) > 0 else 0.0
511
+
512
+ # --- Новые Macro метрики (с явной проверкой) ---
513
+ assembly_punct_recall_macro = sum(assembly_recall_list) / len(assembly_recall_list) if assembly_recall_list else None
514
+ doc_recall_macro = sum(doc_recall_list) / len(doc_recall_list) if doc_recall_list else None
515
+ avg_spurious_docs = sum(spurious_docs_list) / len(spurious_docs_list) if spurious_docs_list else None
516
+
517
+ # Заполняем результат (без изменений)
518
+ final_metrics_results[n] = {
519
+ 'macro_precision': macro_precision,
520
+ 'macro_recall': macro_recall,
521
+ 'macro_f1': macro_f1,
522
+ 'weighted_precision': weighted_precision,
523
+ 'weighted_recall': weighted_recall,
524
+ 'weighted_f1': weighted_f1,
525
+ 'micro_precision': micro_precision,
526
+ 'micro_recall': micro_recall,
527
+ 'micro_f1': micro_f1,
528
+ 'assembly_punct_recall_macro': assembly_punct_recall_macro,
529
+ 'doc_recall_macro': doc_recall_macro,
530
+ 'avg_spurious_docs': avg_spurious_docs,
531
+ }
532
+ logger.info(f"Final metrics for top_n={n}: {final_metrics_results[n]}\n")
533
+
534
+ # --- Расчет Micro Assembly Punct Recall (теперь использует накопленные значения) ---
535
+ micro_assembly_punct_recall = (
536
+ overall_assembly_found_puncts / overall_valid_gt_for_assembly
537
+ if overall_valid_gt_for_assembly > 0 else 0.0
538
+ )
539
+
540
+ # --- Поиск худших вопросов (по Assembly Recall) ---
541
+ qid_to_ground_truths = {item['question_id']: item['ground_truth_texts'] for item in processed_items}
542
+ worst_questions_processed = []
543
+
544
+ logger.debug(f"Debugging worst questions: question_performance = {question_performance}")
545
+
546
+ # +++ Сортируем по assembly_recall_for_worst +++
547
+ sorted_performance = sorted(
548
+ [
549
+ (qid, data) for qid, data in question_performance.items()
550
+ # !!! КЛЮЧЕВОЙ ФИЛЬТР !!! Убедимся, что assembly_recall_for_worst не None
551
+ if data.get('assembly_recall_for_worst') is not None
552
+ ],
553
+ key=lambda item: item[1]['assembly_recall_for_worst'] # Сортируем по recall ПО ВОЗРАСТАНИЮ
554
+ )
555
+
556
+ # +++ ДОБАВЛЯЕМ ЛОГ ПОСЛЕ СОРТИРОВКИ +++
557
+ logger.debug(f"Debugging worst questions: sorted_performance (top {top_worst_k}) = {sorted_performance[:top_worst_k]}")
558
+ # +++ КОНЕЦ ЛОГА +++
559
+
560
+ # +++ ДОБАВЛЯЕМ ЛОГИ ВНУТРИ ЦИКЛА +++
561
+ for qid, perf_data in sorted_performance[:top_worst_k]:
562
+ logger.debug(f"Processing worst question: QID={qid}, Data={perf_data}")
563
+ try:
564
+ missed_indices = perf_data.get('missed_gt_indices', [])
565
+ logger.debug(f"QID={qid}: Got missed_indices: {missed_indices}")
566
+
567
+ missed_texts = []
568
+ if missed_indices is not None and qid in qid_to_ground_truths:
569
+ original_gts = qid_to_ground_truths[qid]
570
+ missed_texts = [original_gts[i] for i in missed_indices if i < len(original_gts)]
571
+ logger.debug(f"QID={qid}: Found {len(missed_texts)} missed texts from {len(original_gts)} original GTs.")
572
+ elif qid not in qid_to_ground_truths:
573
+ logger.warning(f"QID={qid} not found in qid_to_ground_truths when processing worst questions.")
574
+
575
+ # Формируем словарь перед добавлением
576
+ worst_entry = {
577
+ 'id': qid,
578
+ 'f1': perf_data.get('f1'), # Используем .get() для безопасности
579
+ 'assembly_recall': perf_data.get('assembly_recall_for_worst'),
580
+ 'text': perf_data.get('question_text'),
581
+ 'reference_answer': perf_data.get('reference_answer'),
582
+ 'missed_ground_truths': missed_texts
583
+ }
584
+ logger.debug(f"QID={qid}: Appending entry: {worst_entry}")
585
+ worst_questions_processed.append(worst_entry)
586
+
587
+ except Exception as e:
588
+ logger.error(f"Error processing worst question QID={qid}: {e}", exc_info=True)
589
+ # Не прерываем цикл, но логируем ошибку
590
+ # +++ КОНЕЦ ЛОГОВ ВНУТРИ ЦИКЛА +++
591
+
592
+ # --- Формируем финальный ответ ---
593
+ metrics_for_max_n = final_metrics_results.get(max_top_n, {})
594
+ overall_total_found_micro = overall_micro_counters[max_top_n]['found']
595
+ overall_total_gt_micro = overall_micro_counters[max_top_n]['gt']
596
+
597
+ # --- Логирование перед ответом (Оставляем) ---
598
+ logger.debug(f"Final Response Prep: max_top_n={max_top_n}")
599
+ logger.debug(f"Final Response Prep: metrics_for_max_n={metrics_for_max_n}")
600
+ logger.debug(f"Final Response Prep: overall_micro_counters={overall_micro_counters}")
601
+ logger.debug(f"Final Response Prep: micro_recall_for_human_readable = {metrics_for_max_n.get('micro_recall')}")
602
+ # --- Конец лога ---
603
+
604
+ # +++ Перестраиваем структуру ответа с РУССКИМИ КЛЮЧАМИ +++
605
+ final_response = {
606
+ # --- Человекочитаемые метрики --- (Вверху)
607
+ "Найдено пунктов (всего)": overall_total_found_micro,
608
+ "Всего пунктов (эталон)": overall_total_gt_micro,
609
+ "% найденных пунктов (чанк присутствует в пункте)": metrics_for_max_n.get('micro_recall'), # Micro Recall
610
+ "% пунктов были найдены в собранной версии": micro_assembly_punct_recall, # Micro Assembly Recall
611
+ "В среднем для каждого вопроса найден такой % пунктов": metrics_for_max_n.get('macro_recall'), # Macro Recall
612
+ "В среднем для каждого вопроса найден такой % документов": metrics_for_max_n.get('doc_recall_macro'), # Macro Doc Recall
613
+ "В среднем для каждого вопроса найдено N лишних документов, N": metrics_for_max_n.get('avg_spurious_docs'), # Avg Spurious Docs
614
+ # --- Результаты по top_n --- (В середине)
615
+ "results": final_metrics_results,
616
+ # --- Худшие вопросы --- (Внизу)
617
+ "worst_performing_questions": worst_questions_processed,
618
+ }
619
+ return final_response
main.py CHANGED
@@ -1,8 +1,8 @@
1
  import logging
2
  import os
3
- from contextlib import asynccontextmanager
4
  from pathlib import Path
5
- from typing import Annotated
6
 
7
  import dotenv
8
  import uvicorn
@@ -10,38 +10,35 @@ from fastapi import FastAPI
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from transformers import AutoModel, AutoTokenizer
12
 
13
- # from routes.acronym import router as acronym_router
14
- from common import dependencies as DI
15
  from common.common import configure_logging
16
  from common.configuration import Configuration
 
17
  from routes.dataset import router as dataset_router
18
  from routes.document import router as document_router
19
  from routes.entity import router as entity_router
 
20
  from routes.llm import router as llm_router
21
  from routes.llm_config import router as llm_config_router
22
  from routes.llm_prompt import router as llm_prompt_router
23
- from routes.auth import router as auth_router
24
-
25
- # from main_before import config
26
 
 
 
 
 
27
 
28
  # Загружаем переменные из .env
29
  dotenv.load_dotenv()
30
 
31
- # from routes.feedback import router as feedback_router
32
- # from routes.llm import router as llm_router
33
- # from routes.log import router as log_router
34
-
35
  CONFIG_PATH = os.environ.get('CONFIG_PATH', 'config_dev.yaml')
36
  print("config path: ")
37
  print(CONFIG_PATH)
38
  config = Configuration(CONFIG_PATH)
39
 
40
  logger = logging.getLogger(__name__)
41
- configure_logging(config_file_path=config.common_config.log_file_path)
42
 
43
  configure_logging(
44
- level=logging.DEBUG,
45
  config_file_path=config.common_config.log_file_path,
46
  )
47
 
@@ -67,20 +64,20 @@ app.add_middleware(
67
  )
68
 
69
  app.include_router(llm_router)
70
- # app.include_router(log_router)
71
- # app.include_router(feedback_router)
72
  app.include_router(dataset_router)
73
  app.include_router(document_router)
74
  app.include_router(llm_config_router)
75
  app.include_router(llm_prompt_router)
76
  app.include_router(entity_router)
 
77
  app.include_router(auth_router)
78
 
 
79
  if __name__ == "__main__":
80
  uvicorn.run(
81
  "main:app",
82
  host="localhost",
83
- port=7860,
84
- reload=False,
85
  workers=1
86
  )
 
1
  import logging
2
  import os
3
+ from contextlib import asynccontextmanager # noqa: F401
4
  from pathlib import Path
5
+ from typing import Annotated # noqa: F401
6
 
7
  import dotenv
8
  import uvicorn
 
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from transformers import AutoModel, AutoTokenizer
12
 
13
+ from common import dependencies as DI # noqa: F401
 
14
  from common.common import configure_logging
15
  from common.configuration import Configuration
16
+ from routes.auth import router as auth_router
17
  from routes.dataset import router as dataset_router
18
  from routes.document import router as document_router
19
  from routes.entity import router as entity_router
20
+ from routes.evaluation import router as evaluation_router
21
  from routes.llm import router as llm_router
22
  from routes.llm_config import router as llm_config_router
23
  from routes.llm_prompt import router as llm_prompt_router
 
 
 
24
 
25
+ # Защита от автоудаления линтером
26
+ _ = DI
27
+ _ = Annotated
28
+ _ = asynccontextmanager
29
 
30
  # Загружаем переменные из .env
31
  dotenv.load_dotenv()
32
 
 
 
 
 
33
  CONFIG_PATH = os.environ.get('CONFIG_PATH', 'config_dev.yaml')
34
  print("config path: ")
35
  print(CONFIG_PATH)
36
  config = Configuration(CONFIG_PATH)
37
 
38
  logger = logging.getLogger(__name__)
 
39
 
40
  configure_logging(
41
+ level=config.common_config.log_level,
42
  config_file_path=config.common_config.log_file_path,
43
  )
44
 
 
64
  )
65
 
66
  app.include_router(llm_router)
 
 
67
  app.include_router(dataset_router)
68
  app.include_router(document_router)
69
  app.include_router(llm_config_router)
70
  app.include_router(llm_prompt_router)
71
  app.include_router(entity_router)
72
+ app.include_router(evaluation_router)
73
  app.include_router(auth_router)
74
 
75
+
76
  if __name__ == "__main__":
77
  uvicorn.run(
78
  "main:app",
79
  host="localhost",
80
+ port=8885,
81
+ reload=True,
82
  workers=1
83
  )
routes/entity.py CHANGED
@@ -91,7 +91,7 @@ async def search_entities_with_text(
91
  try:
92
  # Получаем результаты поиска
93
  _, scores, entity_ids = entity_service.search_similar_old(
94
- request.query, request.dataset_id
95
  )
96
 
97
  # Проверяем, что scores и entity_ids - корректные numpy массивы
 
91
  try:
92
  # Получаем результаты поиска
93
  _, scores, entity_ids = entity_service.search_similar_old(
94
+ request.query, request.dataset_id, 100
95
  )
96
 
97
  # Проверяем, что scores и entity_ids - корректные numpy массивы
routes/evaluation.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Annotated, Any
2
+
3
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
4
+
5
+ import common.dependencies as DI
6
+ from common import auth
7
+ from components.services.search_metrics import SearchMetricsService
8
+ from schemas.evaluation import EvaluationParams, EvaluationResponse
9
+
10
+ # Создание роутера
11
+ router = APIRouter(prefix="/evaluate", tags=["Evaluation"])
12
+
13
+ # Важно: добавить импорт logger, если его нет
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Определение эндпоинта
19
+ @router.post(
20
+ "/from_file/{dataset_id}",
21
+ response_model=EvaluationResponse,
22
+ summary="Оценка RAG по файлу",
23
+ description="Загружает XLSX файл с вопросами/ответами и рассчитывает метрики RAG (Precision, Recall, F1) для указанного dataset_id и различных значений top_n. Опционально применяет Query Expansion."
24
+ )
25
+ async def evaluate_rag_from_file(
26
+ dataset_id: int,
27
+ params: Annotated[EvaluationParams, Depends()],
28
+ file: Annotated[UploadFile, File(description="XLSX файл с колонками 'id', 'question', 'text' (эталонные ответы через \\n)")],
29
+ metrics_service: Annotated[SearchMetricsService, Depends(DI.get_search_metrics_service)],
30
+ current_user: Annotated[any, Depends(auth.get_current_user)], # Защита эндпоинта
31
+ ) -> Any: # Возвращаем Any, т.к. сервис возвращает dict, а FastAPI валидирует по response_model
32
+ """Эндпоинт для оценки RAG.
33
+
34
+ - Принимает ID датасета в пути.
35
+ - Принимает параметры оценки (порог, top_n, use_query_expansion) и файл как multipart/form-data.
36
+ - Вызывает SearchMetricsService для выполнения расчетов.
37
+ - Возвращает рассчитанные метрики.
38
+ """
39
+ try:
40
+ # --- Вызываем сервис, он теперь возвращает полный словарь ---
41
+ evaluation_full_results = await metrics_service.evaluate_from_file(
42
+ file=file,
43
+ dataset_id=dataset_id,
44
+ similarity_threshold=params.similarity_threshold,
45
+ top_n_values=params.top_n_values,
46
+ use_query_expansion=params.use_query_expansion,
47
+ top_worst_k=params.top_worst_k # Передаем новый параметр
48
+ )
49
+
50
+ # --- Просто возвращаем результат сервиса ---
51
+ # FastAPI сам проверит его по схеме EvaluationResponse
52
+ return evaluation_full_results
53
+
54
+ except HTTPException as e:
55
+ # Просто пробрасываем HTTP ошибки дальше
56
+ raise e
57
+ except Exception as e:
58
+ # Логирование ошибки может быть полезно здесь
59
+ logger.exception("Internal server error during evaluation endpoint execution.") # Пример логирования
60
+ # Ловим другие возможные ошибки во время оценки
61
+ # Логгер уже есть в SearchMetricsService
62
+ raise HTTPException(status_code=500, detail=f"Internal server error during evaluation: {e}")
schemas/evaluation.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ # Определение моделей Pydantic
7
+ class EvaluationParams(BaseModel):
8
+ similarity_threshold: float = Field(
9
+ ...,
10
+ ge=0.0,
11
+ le=1.0,
12
+ description="Порог схожести для fuzzy сравнения (от 0.0 до 1.0)",
13
+ examples=[0.7]
14
+ )
15
+ top_n_values: list[int] = Field(
16
+ ...,
17
+ min_items=1,
18
+ description="Список значений Top-N для оценки",
19
+ examples=[[10, 20, 50]]
20
+ )
21
+ use_query_expansion: bool = Field(
22
+ default=False,
23
+ description="Использовать ли Query Expansion перед поиском для каждого вопроса",
24
+ examples=[True]
25
+ )
26
+ top_worst_k: int = Field(
27
+ default=5,
28
+ ge=1,
29
+ description="Количество худших вопросов для вывода",
30
+ examples=[5]
31
+ )
32
+
33
+ class Metrics(BaseModel):
34
+ macro_precision: float | None
35
+ macro_recall: float | None
36
+ macro_f1: float | None
37
+ weighted_precision: float | None
38
+ weighted_recall: float | None
39
+ weighted_f1: float | None
40
+ micro_precision: float | None
41
+ micro_recall: float | None
42
+ micro_f1: float | None
43
+ assembly_punct_recall_macro: float | None = Field(
44
+ None, description="Macro-усредненный Recall найденных пунктов в собранном контексте"
45
+ )
46
+ doc_recall_macro: float | None = Field(
47
+ None, description="Macro-усредненный Recall найденных эталонных документов в собранном контексте"
48
+ )
49
+ avg_spurious_docs: float | None = Field(
50
+ None, description="Среднее количество 'лишних' документов (найденных, но не ожидаемых) на вопрос"
51
+ )
52
+
53
+ class EvaluationResponse(BaseModel):
54
+ total_found_puncts_overall: int | None = Field(
55
+ None, alias="Найдено пунктов (всего)"
56
+ )
57
+ total_ground_truth_puncts_overall: int | None = Field(
58
+ None, alias="Всего пунктов (эталон)"
59
+ )
60
+ human_readable_chunk_micro_recall: float | None = Field(
61
+ None, alias="% найденных пунктов (чанк присутствует в пункте)"
62
+ )
63
+ human_readable_assembly_micro_recall: float | None = Field(
64
+ None, alias="% пунктов были найдены в собранной версии"
65
+ )
66
+ human_readable_chunk_macro_recall: float | None = Field(
67
+ None, alias="В среднем для каждого вопроса найден такой % пунктов"
68
+ )
69
+ human_readable_doc_macro_recall: float | None = Field(
70
+ None, alias="В среднем для каждого вопроса найден такой % документов"
71
+ )
72
+ human_readable_avg_spurious_docs: float | None = Field(
73
+ None, alias="В среднем для каждого вопроса найдено N лишних документов, N"
74
+ )
75
+ results: dict[int, Metrics] = Field(
76
+ ...,
77
+ description="Словарь с метриками для каждого значения top_n"
78
+ )
79
+ worst_performing_questions: list[dict[str, Any]] | None = Field(
80
+ None, description="Список вопросов с наихудшими показателями (по Assembly Recall)"
81
+ )