Spaces:
Sleeping
Sleeping
update
Browse files- common/auth.py +1 -1
- common/dependencies.py +37 -27
- components/llm/prompts.py +159 -0
- components/search/appendices_chunker.py +104 -0
- components/search/faiss_vector_search.py +56 -0
- components/services/dataset.py +38 -22
- components/services/dialogue.py +11 -1
- components/services/entity.py +62 -30
- lib/extractor/ntr_text_fragmentation/__init__.py +13 -5
- lib/extractor/ntr_text_fragmentation/chunking/__init__.py +2 -0
- lib/extractor/ntr_text_fragmentation/chunking/chunking_strategy.py +21 -0
- lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size_chunking.py +6 -0
- lib/extractor/ntr_text_fragmentation/chunking/text_to_text_base.py +30 -5
- lib/extractor/ntr_text_fragmentation/core/extractor.py +69 -11
- routes/llm.py +8 -3
common/auth.py
CHANGED
@@ -9,7 +9,7 @@ import os
|
|
9 |
# Секретный ключ для JWT
|
10 |
SECRET_KEY = os.environ.get("JWT_SECRET", "ooooooh_thats_my_super_secret_key")
|
11 |
ALGORITHM = "HS256"
|
12 |
-
ACCESS_TOKEN_EXPIRE_MINUTES =
|
13 |
|
14 |
# Захардкоженные пользователи
|
15 |
USERS = [
|
|
|
9 |
# Секретный ключ для JWT
|
10 |
SECRET_KEY = os.environ.get("JWT_SECRET", "ooooooh_thats_my_super_secret_key")
|
11 |
ALGORITHM = "HS256"
|
12 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 1440
|
13 |
|
14 |
# Захардкоженные пользователи
|
15 |
USERS = [
|
common/dependencies.py
CHANGED
@@ -42,7 +42,9 @@ def get_embedding_extractor(
|
|
42 |
)
|
43 |
|
44 |
|
45 |
-
def get_chunk_repository(
|
|
|
|
|
46 |
"""Получение репозитория чанков через DI."""
|
47 |
return ChunkRepository(db)
|
48 |
|
@@ -53,32 +55,6 @@ def get_injection_builder(
|
|
53 |
return InjectionBuilder(chunk_repository)
|
54 |
|
55 |
|
56 |
-
def get_entity_service(
|
57 |
-
vectorizer: Annotated[EmbeddingExtractor, Depends(get_embedding_extractor)],
|
58 |
-
chunk_repository: Annotated[ChunkRepository, Depends(get_chunk_repository)],
|
59 |
-
config: Annotated[Configuration, Depends(get_config)],
|
60 |
-
) -> EntityService:
|
61 |
-
"""Получение сервиса для работы с сущностями через DI."""
|
62 |
-
return EntityService(vectorizer, chunk_repository, config)
|
63 |
-
|
64 |
-
|
65 |
-
def get_dataset_service(
|
66 |
-
entity_service: Annotated[EntityService, Depends(get_entity_service)],
|
67 |
-
config: Annotated[Configuration, Depends(get_config)],
|
68 |
-
db: Annotated[sessionmaker, Depends(get_db)],
|
69 |
-
) -> DatasetService:
|
70 |
-
"""Получение сервиса для работы с датасетами через DI."""
|
71 |
-
return DatasetService(entity_service, config, db)
|
72 |
-
|
73 |
-
|
74 |
-
def get_document_service(
|
75 |
-
dataset_service: Annotated[DatasetService, Depends(get_dataset_service)],
|
76 |
-
config: Annotated[Configuration, Depends(get_config)],
|
77 |
-
db: Annotated[sessionmaker, Depends(get_db)],
|
78 |
-
) -> DocumentService:
|
79 |
-
return DocumentService(dataset_service, config, db)
|
80 |
-
|
81 |
-
|
82 |
def get_llm_config_service(db: Annotated[Session, Depends(get_db)]) -> LLMConfigService:
|
83 |
return LLMConfigService(db)
|
84 |
|
@@ -106,6 +82,40 @@ def get_llm_prompt_service(db: Annotated[Session, Depends(get_db)]) -> LlmPrompt
|
|
106 |
return LlmPromptService(db)
|
107 |
|
108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
109 |
def get_dialogue_service(
|
110 |
config: Annotated[Configuration, Depends(get_config)],
|
111 |
entity_service: Annotated[EntityService, Depends(get_entity_service)],
|
|
|
42 |
)
|
43 |
|
44 |
|
45 |
+
def get_chunk_repository(
|
46 |
+
db: Annotated[sessionmaker, Depends(get_db)],
|
47 |
+
) -> ChunkRepository:
|
48 |
"""Получение репозитория чанков через DI."""
|
49 |
return ChunkRepository(db)
|
50 |
|
|
|
55 |
return InjectionBuilder(chunk_repository)
|
56 |
|
57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
def get_llm_config_service(db: Annotated[Session, Depends(get_db)]) -> LLMConfigService:
|
59 |
return LLMConfigService(db)
|
60 |
|
|
|
82 |
return LlmPromptService(db)
|
83 |
|
84 |
|
85 |
+
def get_entity_service(
|
86 |
+
vectorizer: Annotated[EmbeddingExtractor, Depends(get_embedding_extractor)],
|
87 |
+
chunk_repository: Annotated[ChunkRepository, Depends(get_chunk_repository)],
|
88 |
+
config: Annotated[Configuration, Depends(get_config)],
|
89 |
+
llm_api: Annotated[DeepInfraApi, Depends(get_llm_service)],
|
90 |
+
llm_config_service: Annotated[LLMConfigService, Depends(get_llm_config_service)],
|
91 |
+
) -> EntityService:
|
92 |
+
"""Получение сервиса для работы с сущностями через DI."""
|
93 |
+
return EntityService(
|
94 |
+
vectorizer,
|
95 |
+
chunk_repository,
|
96 |
+
config,
|
97 |
+
llm_api,
|
98 |
+
llm_config_service,
|
99 |
+
)
|
100 |
+
|
101 |
+
|
102 |
+
def get_dataset_service(
|
103 |
+
entity_service: Annotated[EntityService, Depends(get_entity_service)],
|
104 |
+
config: Annotated[Configuration, Depends(get_config)],
|
105 |
+
db: Annotated[sessionmaker, Depends(get_db)],
|
106 |
+
) -> DatasetService:
|
107 |
+
"""Получение сервиса для работы с датасетами через DI."""
|
108 |
+
return DatasetService(entity_service, config, db)
|
109 |
+
|
110 |
+
|
111 |
+
def get_document_service(
|
112 |
+
dataset_service: Annotated[DatasetService, Depends(get_dataset_service)],
|
113 |
+
config: Annotated[Configuration, Depends(get_config)],
|
114 |
+
db: Annotated[sessionmaker, Depends(get_db)],
|
115 |
+
) -> DocumentService:
|
116 |
+
return DocumentService(dataset_service, config, db)
|
117 |
+
|
118 |
+
|
119 |
def get_dialogue_service(
|
120 |
config: Annotated[Configuration, Depends(get_config)],
|
121 |
entity_service: Annotated[EntityService, Depends(get_entity_service)],
|
components/llm/prompts.py
CHANGED
@@ -203,3 +203,162 @@ user: Привет. Хочешь поговорить?
|
|
203 |
####
|
204 |
Вывод:
|
205 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
"""
|
components/search/appendices_chunker.py
ADDED
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
|
3 |
+
from ntr_fileparser import ParsedDocument
|
4 |
+
from ntr_text_fragmentation import (
|
5 |
+
ChunkingStrategy,
|
6 |
+
LinkerEntity,
|
7 |
+
register_chunking_strategy,
|
8 |
+
register_entity,
|
9 |
+
DocumentAsEntity,
|
10 |
+
Chunk,
|
11 |
+
)
|
12 |
+
|
13 |
+
from components.llm.common import LlmPredictParams
|
14 |
+
from components.llm.deepinfra_api import DeepInfraApi
|
15 |
+
from components.llm.prompts import PROMPT_APPENDICES
|
16 |
+
from components.services.llm_config import LLMConfigService
|
17 |
+
|
18 |
+
logger = logging.getLogger(__name__)
|
19 |
+
|
20 |
+
|
21 |
+
APPENDICES_CHUNKER = 'appendices'
|
22 |
+
|
23 |
+
|
24 |
+
@register_entity
|
25 |
+
class Appendix(Chunk):
|
26 |
+
"""Сущность для хранения приложений"""
|
27 |
+
|
28 |
+
|
29 |
+
@register_chunking_strategy(APPENDICES_CHUNKER)
|
30 |
+
class AppendicesProcessor(ChunkingStrategy):
|
31 |
+
def __init__(
|
32 |
+
self,
|
33 |
+
llm_api: DeepInfraApi,
|
34 |
+
llm_config_service: LLMConfigService,
|
35 |
+
):
|
36 |
+
self.prompt = PROMPT_APPENDICES
|
37 |
+
self.llm_api = llm_api
|
38 |
+
|
39 |
+
p = llm_config_service.get_default()
|
40 |
+
self.llm_params = LlmPredictParams(
|
41 |
+
temperature=p.temperature,
|
42 |
+
top_p=p.top_p,
|
43 |
+
min_p=p.min_p,
|
44 |
+
seed=p.seed,
|
45 |
+
frequency_penalty=p.frequency_penalty,
|
46 |
+
presence_penalty=p.presence_penalty,
|
47 |
+
n_predict=p.n_predict,
|
48 |
+
)
|
49 |
+
|
50 |
+
def chunk(
|
51 |
+
self, document: ParsedDocument, doc_entity: DocumentAsEntity
|
52 |
+
) -> list[LinkerEntity]:
|
53 |
+
raise NotImplementedError(
|
54 |
+
f"{self.__class__.__name__} поддерживает только асинхронный вызов. "
|
55 |
+
"Используйте метод extract_async или другую стратегию."
|
56 |
+
)
|
57 |
+
|
58 |
+
async def chunk_async(
|
59 |
+
self, document: ParsedDocument, doc_entity: DocumentAsEntity
|
60 |
+
) -> list[LinkerEntity]:
|
61 |
+
text = ""
|
62 |
+
text += document.name + "\n"
|
63 |
+
text += "\n".join([p.text for p in document.paragraphs])
|
64 |
+
text += "\n".join([t.to_string() for t in document.tables])
|
65 |
+
|
66 |
+
prompt = self._format_prompt(text)
|
67 |
+
|
68 |
+
response = await self.llm_api.predict(prompt=prompt, system_prompt=None)
|
69 |
+
processed = self._postprocess_llm_response(response)
|
70 |
+
if processed is None:
|
71 |
+
return []
|
72 |
+
|
73 |
+
entity = Appendix(
|
74 |
+
text=processed,
|
75 |
+
in_search_text=processed,
|
76 |
+
number_in_relation=0,
|
77 |
+
groupper=APPENDICES_CHUNKER,
|
78 |
+
)
|
79 |
+
entity.owner_id = doc_entity.id
|
80 |
+
return [entity]
|
81 |
+
|
82 |
+
def _format_prompt(self, text: str) -> str:
|
83 |
+
return self.prompt.format(replace_me=text)
|
84 |
+
|
85 |
+
def _postprocess_llm_response(self, response: str | None) -> str | None:
|
86 |
+
if response is None:
|
87 |
+
return None
|
88 |
+
# Найти начало и конец текста в квадратных скобках
|
89 |
+
start = response.find('[')
|
90 |
+
end = response.find(']')
|
91 |
+
|
92 |
+
# Проверка, что найдена только одна пара скобок
|
93 |
+
if start == -1 or end == -1 or start >= end:
|
94 |
+
logger.warning(f"Некорректный формат ответа LLM: {response}")
|
95 |
+
return None
|
96 |
+
|
97 |
+
# Извлечь текст внутри скобок
|
98 |
+
extracted_text = response[start + 1 : end]
|
99 |
+
|
100 |
+
if extracted_text == '%%':
|
101 |
+
logging.info(f'Приложение признано бесполезным')
|
102 |
+
return None
|
103 |
+
|
104 |
+
return extracted_text
|
components/search/faiss_vector_search.py
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
|
3 |
+
import faiss
|
4 |
+
import numpy as np
|
5 |
+
|
6 |
+
from common.constants import DO_NORMALIZATION
|
7 |
+
from components.embedding_extraction import EmbeddingExtractor
|
8 |
+
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
|
12 |
+
class FaissVectorSearch:
|
13 |
+
def __init__(
|
14 |
+
self,
|
15 |
+
model: EmbeddingExtractor,
|
16 |
+
ids_to_embeddings: dict[str, np.ndarray],
|
17 |
+
):
|
18 |
+
self.model = model
|
19 |
+
self.index_to_id = {i: id_ for i, id_ in enumerate(ids_to_embeddings.keys())}
|
20 |
+
self.__create_index(ids_to_embeddings)
|
21 |
+
|
22 |
+
def __create_index(self, ids_to_embeddings: dict[str, np.ndarray]):
|
23 |
+
"""Создает индекс для векторного поиска."""
|
24 |
+
if len(ids_to_embeddings) == 0:
|
25 |
+
self.index = None
|
26 |
+
return
|
27 |
+
embeddings = np.array(list(ids_to_embeddings.values()))
|
28 |
+
dim = embeddings.shape[1]
|
29 |
+
self.index = faiss.IndexFlatIP(dim)
|
30 |
+
self.index.add(embeddings)
|
31 |
+
|
32 |
+
def search_vectors(
|
33 |
+
self,
|
34 |
+
query: str,
|
35 |
+
max_entities: int = 100,
|
36 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
37 |
+
"""
|
38 |
+
Поиск векторов в индексе.
|
39 |
+
|
40 |
+
Args:
|
41 |
+
query: Строка, запрос для поиска.
|
42 |
+
max_entities: Максимальное количество найденных сущностей.
|
43 |
+
|
44 |
+
Returns:
|
45 |
+
tuple[np.ndarray, np.ndarray, np.ndarray]: Кортеж из трех массивов:
|
46 |
+
- np.ndarray: Вектор запроса (1, embedding_size)
|
47 |
+
- np.ndarray: Оценки косинусного сходства (чем больше, тем лучше)
|
48 |
+
- np.ndarray: Идентификаторы найденных векторов
|
49 |
+
"""
|
50 |
+
logger.info(f"Searching vectors in index for query: {query}")
|
51 |
+
if self.index is None:
|
52 |
+
return (np.array([]), np.array([]), np.array([]))
|
53 |
+
query_embeds = self.model.query_embed_extraction(query, DO_NORMALIZATION)
|
54 |
+
similarities, indexes = self.index.search(query_embeds, max_entities)
|
55 |
+
ids = [self.index_to_id[index] for index in indexes[0]]
|
56 |
+
return query_embeds, similarities[0], np.array(ids)
|
components/services/dataset.py
CHANGED
@@ -5,6 +5,7 @@ import shutil
|
|
5 |
import zipfile
|
6 |
from datetime import datetime
|
7 |
from pathlib import Path
|
|
|
8 |
|
9 |
import torch
|
10 |
from fastapi import BackgroundTasks, HTTPException, UploadFile
|
@@ -94,11 +95,6 @@ class DatasetService:
|
|
94 |
session.query(Document)
|
95 |
.join(DatasetDocument, DatasetDocument.document_id == Document.id)
|
96 |
.filter(DatasetDocument.dataset_id == dataset_id)
|
97 |
-
.filter(
|
98 |
-
Document.status.in_(
|
99 |
-
['Актуальный', 'Требует актуализации', 'Упразднён']
|
100 |
-
)
|
101 |
-
)
|
102 |
.filter(Document.title.like(f'%{search}%'))
|
103 |
.count()
|
104 |
)
|
@@ -353,6 +349,7 @@ class DatasetService:
|
|
353 |
) -> None:
|
354 |
"""
|
355 |
Сохранить черновик как полноценный датасет.
|
|
|
356 |
|
357 |
Args:
|
358 |
dataset: Датасет для применения
|
@@ -391,21 +388,36 @@ class DatasetService:
|
|
391 |
doc_dataset_link.document for doc_dataset_link in dataset.documents
|
392 |
]
|
393 |
|
394 |
-
|
395 |
-
path = self.documents_path / f'{document.id}.
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
parsed
|
404 |
-
|
405 |
-
|
406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
407 |
|
408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
409 |
|
410 |
def raise_if_processing(self) -> None:
|
411 |
"""
|
@@ -534,8 +546,8 @@ class DatasetService:
|
|
534 |
Создаёт документ в базе данных.
|
535 |
|
536 |
Args:
|
537 |
-
|
538 |
-
subpath: Путь к
|
539 |
dataset: Датасет, к которому относится документ.
|
540 |
|
541 |
Returns:
|
@@ -545,9 +557,13 @@ class DatasetService:
|
|
545 |
|
546 |
try:
|
547 |
source_format = get_source_format(str(subpath))
|
|
|
548 |
parsed: ParsedDocument | None = self.parser.parse_by_path(
|
549 |
-
str(
|
550 |
)
|
|
|
|
|
|
|
551 |
|
552 |
if not parsed:
|
553 |
logger.warning(f"Failed to parse file: {subpath}")
|
|
|
5 |
import zipfile
|
6 |
from datetime import datetime
|
7 |
from pathlib import Path
|
8 |
+
import asyncio
|
9 |
|
10 |
import torch
|
11 |
from fastapi import BackgroundTasks, HTTPException, UploadFile
|
|
|
95 |
session.query(Document)
|
96 |
.join(DatasetDocument, DatasetDocument.document_id == Document.id)
|
97 |
.filter(DatasetDocument.dataset_id == dataset_id)
|
|
|
|
|
|
|
|
|
|
|
98 |
.filter(Document.title.like(f'%{search}%'))
|
99 |
.count()
|
100 |
)
|
|
|
349 |
) -> None:
|
350 |
"""
|
351 |
Сохранить черновик как полноценный датасет.
|
352 |
+
Вызывает асинхронную обработку документов.
|
353 |
|
354 |
Args:
|
355 |
dataset: Датасет для применения
|
|
|
388 |
doc_dataset_link.document for doc_dataset_link in dataset.documents
|
389 |
]
|
390 |
|
391 |
+
async def process_single_document(document: Document):
|
392 |
+
path = self.documents_path / f'{document.id}.{document.source_format}'
|
393 |
+
try:
|
394 |
+
parsed = self.parser.parse_by_path(str(path))
|
395 |
+
if parsed is None:
|
396 |
+
logger.warning(
|
397 |
+
f"Failed to parse document {document.id} at path {path}"
|
398 |
+
)
|
399 |
+
return
|
400 |
+
parsed.name = document.title
|
401 |
+
await self.entity_service.process_document(
|
402 |
+
parsed,
|
403 |
+
dataset.id,
|
404 |
+
progress_callback=progress_callback, # Callback остается синхронным
|
405 |
+
)
|
406 |
+
except Exception as e:
|
407 |
+
logger.error(
|
408 |
+
f"Error processing document {document.id} in apply_draft: {e}",
|
409 |
+
exc_info=True,
|
410 |
+
)
|
411 |
|
412 |
+
async def main_processing():
|
413 |
+
tasks = [process_single_document(doc) for doc in documents]
|
414 |
+
await asyncio.gather(*tasks)
|
415 |
+
|
416 |
+
try:
|
417 |
+
asyncio.run(main_processing())
|
418 |
+
finally:
|
419 |
+
if TMP_PATH.exists():
|
420 |
+
TMP_PATH.unlink()
|
421 |
|
422 |
def raise_if_processing(self) -> None:
|
423 |
"""
|
|
|
546 |
Создаёт документ в базе данных.
|
547 |
|
548 |
Args:
|
549 |
+
documents_path: Путь к директории с документами.
|
550 |
+
subpath: Путь к документу относительно documents_path.
|
551 |
dataset: Датасет, к которому относится документ.
|
552 |
|
553 |
Returns:
|
|
|
557 |
|
558 |
try:
|
559 |
source_format = get_source_format(str(subpath))
|
560 |
+
path = documents_path / subpath
|
561 |
parsed: ParsedDocument | None = self.parser.parse_by_path(
|
562 |
+
str(path)
|
563 |
)
|
564 |
+
|
565 |
+
if 'Приложение' in parsed.name:
|
566 |
+
parsed.name = path.parent.name + ' ' + parsed.name
|
567 |
|
568 |
if not parsed:
|
569 |
logger.warning(f"Failed to parse file: {subpath}")
|
components/services/dialogue.py
CHANGED
@@ -68,7 +68,17 @@ class DialogueService:
|
|
68 |
except Exception as e:
|
69 |
logger.error(f"Error in _postprocess_qe: {e}")
|
70 |
from_chat = self._get_search_query(history)
|
71 |
-
return QEResult(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
|
73 |
def _get_qe_request(self, history: List[Message]) -> ChatRequest:
|
74 |
"""
|
|
|
68 |
except Exception as e:
|
69 |
logger.error(f"Error in _postprocess_qe: {e}")
|
70 |
from_chat = self._get_search_query(history)
|
71 |
+
return QEResult(
|
72 |
+
use_search=from_chat is not None,
|
73 |
+
search_query=from_chat.content if from_chat else None,
|
74 |
+
)
|
75 |
+
|
76 |
+
def get_qe_result_from_chat(self, history: List[Message]) -> QEResult:
|
77 |
+
from_chat = self._get_search_query(history)
|
78 |
+
return QEResult(
|
79 |
+
use_search=from_chat is not None,
|
80 |
+
search_query=from_chat.content if from_chat else None,
|
81 |
+
)
|
82 |
|
83 |
def _get_qe_request(self, history: List[Message]) -> ChatRequest:
|
84 |
"""
|
components/services/entity.py
CHANGED
@@ -2,15 +2,17 @@ import logging
|
|
2 |
from typing import Callable, Optional
|
3 |
from uuid import UUID
|
4 |
|
5 |
-
from ntr_fileparser import ParsedDocument
|
6 |
-
from ntr_text_fragmentation import (EntitiesExtractor, InjectionBuilder,
|
7 |
-
LinkerEntity)
|
8 |
import numpy as np
|
|
|
|
|
9 |
|
10 |
from common.configuration import Configuration
|
11 |
from components.dbo.chunk_repository import ChunkRepository
|
12 |
from components.embedding_extraction import EmbeddingExtractor
|
13 |
-
from components.
|
|
|
|
|
|
|
14 |
|
15 |
logger = logging.getLogger(__name__)
|
16 |
|
@@ -26,6 +28,8 @@ class EntityService:
|
|
26 |
vectorizer: EmbeddingExtractor,
|
27 |
chunk_repository: ChunkRepository,
|
28 |
config: Configuration,
|
|
|
|
|
29 |
) -> None:
|
30 |
"""
|
31 |
Инициализация сервиса.
|
@@ -34,22 +38,36 @@ class EntityService:
|
|
34 |
vectorizer: Модель для извлечения эмбеддингов
|
35 |
chunk_repository: Репозиторий для работы с чанками
|
36 |
config: Конфигурация приложения
|
|
|
|
|
37 |
"""
|
38 |
self.vectorizer = vectorizer
|
39 |
self.config = config
|
40 |
self.chunk_repository = chunk_repository
|
41 |
-
self.
|
42 |
-
self.
|
43 |
-
|
|
|
|
|
44 |
self.neighbors_max_distance = config.db_config.entities.neighbors_max_distance
|
45 |
self.max_entities_per_message = config.db_config.search.max_entities_per_message
|
46 |
-
self.max_entities_per_dialogue =
|
|
|
|
|
47 |
|
48 |
-
self.
|
49 |
strategy_name=config.db_config.entities.strategy_name,
|
50 |
strategy_params=config.db_config.entities.strategy_params,
|
51 |
process_tables=config.db_config.entities.process_tables,
|
52 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
|
54 |
def _ensure_faiss_initialized(self, dataset_id: int) -> None:
|
55 |
"""
|
@@ -65,7 +83,6 @@ class EntityService:
|
|
65 |
dataset_id
|
66 |
)
|
67 |
if entities:
|
68 |
-
# Создаем словарь только из не-None эмбеддингов
|
69 |
embeddings_dict = {
|
70 |
str(entity.id): embedding # Преобразуем UUID в строку для ключа
|
71 |
for entity, embedding in zip(entities, embeddings)
|
@@ -91,14 +108,14 @@ class EntityService:
|
|
91 |
self.faiss_search = None
|
92 |
self.current_dataset_id = None
|
93 |
|
94 |
-
def process_document(
|
95 |
self,
|
96 |
document: ParsedDocument,
|
97 |
dataset_id: int,
|
98 |
progress_callback: Optional[Callable] = None,
|
99 |
) -> None:
|
100 |
"""
|
101 |
-
|
102 |
|
103 |
Args:
|
104 |
document: Документ для обработки
|
@@ -107,8 +124,10 @@ class EntityService:
|
|
107 |
"""
|
108 |
logger.info(f"Processing document {document.name} for dataset {dataset_id}")
|
109 |
|
110 |
-
|
111 |
-
|
|
|
|
|
112 |
|
113 |
# Фильтруем сущности для поиска
|
114 |
filtering_entities = [
|
@@ -116,10 +135,9 @@ class EntityService:
|
|
116 |
]
|
117 |
filtering_texts = [entity.in_search_text for entity in filtering_entities]
|
118 |
|
119 |
-
# Получаем эмбеддинги с поддержкой callback
|
120 |
embeddings = self.vectorizer.vectorize(filtering_texts, progress_callback)
|
121 |
embeddings_dict = {
|
122 |
-
str(entity.id): embedding
|
123 |
for entity, embedding in zip(filtering_entities, embeddings)
|
124 |
}
|
125 |
|
@@ -176,20 +194,20 @@ class EntityService:
|
|
176 |
dataset_id: ID датасета
|
177 |
|
178 |
Returns:
|
179 |
-
tuple[np.ndarray, np.ndarray, np.ndarray]:
|
180 |
- Вектор запроса
|
181 |
- Оценки сходства
|
182 |
- Идентификаторы найденных сущностей
|
183 |
"""
|
184 |
# Убеждаемся, что FAISS инициализирован для текущего датасета
|
185 |
self._ensure_faiss_initialized(dataset_id)
|
186 |
-
|
187 |
if self.faiss_search is None:
|
188 |
return np.array([]), np.array([]), np.array([])
|
189 |
-
|
190 |
# Выполняем поиск
|
191 |
return self.faiss_search.search_vectors(query)
|
192 |
-
|
193 |
def search_similar(
|
194 |
self,
|
195 |
query: str,
|
@@ -214,9 +232,14 @@ class EntityService:
|
|
214 |
|
215 |
if self.faiss_search is None:
|
216 |
return previous_entities, [], []
|
217 |
-
|
218 |
-
if
|
219 |
-
|
|
|
|
|
|
|
|
|
|
|
220 |
try:
|
221 |
scores = scores.tolist()
|
222 |
ids = ids.tolist()
|
@@ -226,14 +249,21 @@ class EntityService:
|
|
226 |
return previous_entities, ids, scores
|
227 |
|
228 |
if previous_entities:
|
229 |
-
_, scores, ids = self.faiss_search.search_vectors(
|
|
|
|
|
230 |
scores = scores.tolist()
|
231 |
ids = ids.tolist()
|
232 |
-
|
233 |
print(ids)
|
234 |
|
235 |
-
previous_entities_ids = [
|
236 |
-
|
|
|
|
|
|
|
|
|
|
|
237 |
new_entities = []
|
238 |
new_scores = []
|
239 |
for id_, score in zip(ids, scores):
|
@@ -242,11 +272,13 @@ class EntityService:
|
|
242 |
new_scores.append(score)
|
243 |
if len(new_entities) >= self.max_entities_per_message:
|
244 |
break
|
245 |
-
|
246 |
return previous_entities, new_entities, new_scores
|
247 |
-
|
248 |
else:
|
249 |
-
_, scores, ids = self.faiss_search.search_vectors(
|
|
|
|
|
250 |
scores = scores.tolist()
|
251 |
ids = ids.tolist()
|
252 |
return [], ids, scores
|
|
|
2 |
from typing import Callable, Optional
|
3 |
from uuid import UUID
|
4 |
|
|
|
|
|
|
|
5 |
import numpy as np
|
6 |
+
from ntr_fileparser import ParsedDocument
|
7 |
+
from ntr_text_fragmentation import EntitiesExtractor, InjectionBuilder
|
8 |
|
9 |
from common.configuration import Configuration
|
10 |
from components.dbo.chunk_repository import ChunkRepository
|
11 |
from components.embedding_extraction import EmbeddingExtractor
|
12 |
+
from components.llm.deepinfra_api import DeepInfraApi
|
13 |
+
from components.search.appendices_chunker import APPENDICES_CHUNKER
|
14 |
+
from components.search.faiss_vector_search import FaissVectorSearch
|
15 |
+
from components.services.llm_config import LLMConfigService
|
16 |
|
17 |
logger = logging.getLogger(__name__)
|
18 |
|
|
|
28 |
vectorizer: EmbeddingExtractor,
|
29 |
chunk_repository: ChunkRepository,
|
30 |
config: Configuration,
|
31 |
+
llm_api: DeepInfraApi,
|
32 |
+
llm_config_service: LLMConfigService,
|
33 |
) -> None:
|
34 |
"""
|
35 |
Инициализация сервиса.
|
|
|
38 |
vectorizer: Модель для извлечения эмбеддингов
|
39 |
chunk_repository: Репозиторий для работы с чанками
|
40 |
config: Конфигурация приложения
|
41 |
+
llm_api: Клиент для взаимодействия с LLM API
|
42 |
+
llm_config_service: Сервис для получения конфигурации LLM
|
43 |
"""
|
44 |
self.vectorizer = vectorizer
|
45 |
self.config = config
|
46 |
self.chunk_repository = chunk_repository
|
47 |
+
self.llm_api = llm_api
|
48 |
+
self.llm_config_service = llm_config_service
|
49 |
+
self.faiss_search = None
|
50 |
+
self.current_dataset_id = None
|
51 |
+
|
52 |
self.neighbors_max_distance = config.db_config.entities.neighbors_max_distance
|
53 |
self.max_entities_per_message = config.db_config.search.max_entities_per_message
|
54 |
+
self.max_entities_per_dialogue = (
|
55 |
+
config.db_config.search.max_entities_per_dialogue
|
56 |
+
)
|
57 |
|
58 |
+
self.main_extractor = EntitiesExtractor(
|
59 |
strategy_name=config.db_config.entities.strategy_name,
|
60 |
strategy_params=config.db_config.entities.strategy_params,
|
61 |
process_tables=config.db_config.entities.process_tables,
|
62 |
)
|
63 |
+
self.appendices_extractor = EntitiesExtractor(
|
64 |
+
strategy_name=APPENDICES_CHUNKER,
|
65 |
+
strategy_params={
|
66 |
+
"llm_api": self.llm_api,
|
67 |
+
"llm_config_service": self.llm_config_service,
|
68 |
+
},
|
69 |
+
process_tables=False,
|
70 |
+
)
|
71 |
|
72 |
def _ensure_faiss_initialized(self, dataset_id: int) -> None:
|
73 |
"""
|
|
|
83 |
dataset_id
|
84 |
)
|
85 |
if entities:
|
|
|
86 |
embeddings_dict = {
|
87 |
str(entity.id): embedding # Преобразуем UUID в строку для ключа
|
88 |
for entity, embedding in zip(entities, embeddings)
|
|
|
108 |
self.faiss_search = None
|
109 |
self.current_dataset_id = None
|
110 |
|
111 |
+
async def process_document(
|
112 |
self,
|
113 |
document: ParsedDocument,
|
114 |
dataset_id: int,
|
115 |
progress_callback: Optional[Callable] = None,
|
116 |
) -> None:
|
117 |
"""
|
118 |
+
Асинхронная обработка документа: разбиение на чанки и сохранение в базу.
|
119 |
|
120 |
Args:
|
121 |
document: Документ для обработки
|
|
|
124 |
"""
|
125 |
logger.info(f"Processing document {document.name} for dataset {dataset_id}")
|
126 |
|
127 |
+
if 'Приложение' in document.name:
|
128 |
+
entities = await self.appendices_extractor.extract_async(document)
|
129 |
+
else:
|
130 |
+
entities = await self.main_extractor.extract_async(document)
|
131 |
|
132 |
# Фильтруем сущности для поиска
|
133 |
filtering_entities = [
|
|
|
135 |
]
|
136 |
filtering_texts = [entity.in_search_text for entity in filtering_entities]
|
137 |
|
|
|
138 |
embeddings = self.vectorizer.vectorize(filtering_texts, progress_callback)
|
139 |
embeddings_dict = {
|
140 |
+
str(entity.id): embedding
|
141 |
for entity, embedding in zip(filtering_entities, embeddings)
|
142 |
}
|
143 |
|
|
|
194 |
dataset_id: ID датасета
|
195 |
|
196 |
Returns:
|
197 |
+
tuple[np.ndarray, np.ndarray, np.ndarray]:
|
198 |
- Вектор запроса
|
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,
|
213 |
query: str,
|
|
|
232 |
|
233 |
if self.faiss_search is None:
|
234 |
return previous_entities, [], []
|
235 |
+
|
236 |
+
if (
|
237 |
+
sum(len(entities) for entities in previous_entities)
|
238 |
+
< self.max_entities_per_dialogue - self.max_entities_per_message
|
239 |
+
):
|
240 |
+
_, scores, ids = self.faiss_search.search_vectors(
|
241 |
+
query, self.max_entities_per_message
|
242 |
+
)
|
243 |
try:
|
244 |
scores = scores.tolist()
|
245 |
ids = ids.tolist()
|
|
|
249 |
return previous_entities, ids, scores
|
250 |
|
251 |
if previous_entities:
|
252 |
+
_, scores, ids = self.faiss_search.search_vectors(
|
253 |
+
query, self.max_entities_per_dialogue
|
254 |
+
)
|
255 |
scores = scores.tolist()
|
256 |
ids = ids.tolist()
|
257 |
+
|
258 |
print(ids)
|
259 |
|
260 |
+
previous_entities_ids = [
|
261 |
+
[entity for entity in sublist if entity in ids]
|
262 |
+
for sublist in previous_entities
|
263 |
+
]
|
264 |
+
previous_entities_flat = [
|
265 |
+
entity for sublist in previous_entities_ids for entity in sublist
|
266 |
+
]
|
267 |
new_entities = []
|
268 |
new_scores = []
|
269 |
for id_, score in zip(ids, scores):
|
|
|
272 |
new_scores.append(score)
|
273 |
if len(new_entities) >= self.max_entities_per_message:
|
274 |
break
|
275 |
+
|
276 |
return previous_entities, new_entities, new_scores
|
277 |
+
|
278 |
else:
|
279 |
+
_, scores, ids = self.faiss_search.search_vectors(
|
280 |
+
query, self.max_entities_per_dialogue
|
281 |
+
)
|
282 |
scores = scores.tolist()
|
283 |
ids = ids.tolist()
|
284 |
return [], ids, scores
|
lib/extractor/ntr_text_fragmentation/__init__.py
CHANGED
@@ -2,12 +2,16 @@
|
|
2 |
Модуль извлечения и сборки документов.
|
3 |
"""
|
4 |
|
5 |
-
from .core
|
6 |
-
from .repositories
|
7 |
-
from .core.injection_builder import InjectionBuilder
|
8 |
-
from .repositories import InMemoryEntityRepository
|
9 |
from .models import DocumentAsEntity, LinkerEntity, Link, Entity, register_entity
|
10 |
-
from .chunking import
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
|
12 |
__all__ = [
|
13 |
"EntitiesExtractor",
|
@@ -21,4 +25,8 @@ __all__ = [
|
|
21 |
"DocumentAsEntity",
|
22 |
"integrations",
|
23 |
"FIXED_SIZE",
|
|
|
|
|
|
|
|
|
24 |
]
|
|
|
2 |
Модуль извлечения и сборки документов.
|
3 |
"""
|
4 |
|
5 |
+
from .core import EntitiesExtractor, InjectionBuilder
|
6 |
+
from .repositories import EntityRepository, InMemoryEntityRepository
|
|
|
|
|
7 |
from .models import DocumentAsEntity, LinkerEntity, Link, Entity, register_entity
|
8 |
+
from .chunking import (
|
9 |
+
FIXED_SIZE,
|
10 |
+
TextToTextBaseStrategy,
|
11 |
+
ChunkingStrategy,
|
12 |
+
register_chunking_strategy,
|
13 |
+
Chunk,
|
14 |
+
)
|
15 |
|
16 |
__all__ = [
|
17 |
"EntitiesExtractor",
|
|
|
25 |
"DocumentAsEntity",
|
26 |
"integrations",
|
27 |
"FIXED_SIZE",
|
28 |
+
"TextToTextBaseStrategy",
|
29 |
+
"ChunkingStrategy",
|
30 |
+
"register_chunking_strategy",
|
31 |
+
"Chunk",
|
32 |
]
|
lib/extractor/ntr_text_fragmentation/chunking/__init__.py
CHANGED
@@ -9,6 +9,7 @@ from .specific_strategies import (
|
|
9 |
FIXED_SIZE,
|
10 |
)
|
11 |
from .text_to_text_base import TextToTextBaseStrategy
|
|
|
12 |
|
13 |
from .chunking_registry import register_chunking_strategy, chunking_registry
|
14 |
|
@@ -20,4 +21,5 @@ __all__ = [
|
|
20 |
"TextToTextBaseStrategy",
|
21 |
"register_chunking_strategy",
|
22 |
"chunking_registry",
|
|
|
23 |
]
|
|
|
9 |
FIXED_SIZE,
|
10 |
)
|
11 |
from .text_to_text_base import TextToTextBaseStrategy
|
12 |
+
from .models import Chunk
|
13 |
|
14 |
from .chunking_registry import register_chunking_strategy, chunking_registry
|
15 |
|
|
|
21 |
"TextToTextBaseStrategy",
|
22 |
"register_chunking_strategy",
|
23 |
"chunking_registry",
|
24 |
+
"Chunk",
|
25 |
]
|
lib/extractor/ntr_text_fragmentation/chunking/chunking_strategy.py
CHANGED
@@ -35,6 +35,27 @@ class ChunkingStrategy(ABC):
|
|
35 |
"""
|
36 |
raise NotImplementedError("Стратегия чанкинга должна реализовать метод chunk")
|
37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
@classmethod
|
39 |
def dechunk(
|
40 |
cls,
|
|
|
35 |
"""
|
36 |
raise NotImplementedError("Стратегия чанкинга должна реализовать метод chunk")
|
37 |
|
38 |
+
@abstractmethod
|
39 |
+
async def chunk_async(
|
40 |
+
self,
|
41 |
+
document: ParsedDocument,
|
42 |
+
doc_entity: DocumentAsEntity,
|
43 |
+
) -> list[LinkerEntity]:
|
44 |
+
"""
|
45 |
+
Асинхронно разбивает документ на чанки в соответствии со стратегией.
|
46 |
+
|
47 |
+
Args:
|
48 |
+
document: ParsedDocument для извлечения текста и структуры.
|
49 |
+
doc_entity: Сущность документа-владельца, к которой будут привязаны чанки.
|
50 |
+
|
51 |
+
Returns:
|
52 |
+
Список сущностей (чанки)
|
53 |
+
"""
|
54 |
+
logger.warning(
|
55 |
+
"Асинхронная стратегия чанкинга не реализована, вызывается синхронная"
|
56 |
+
)
|
57 |
+
return self.chunk(document, doc_entity)
|
58 |
+
|
59 |
@classmethod
|
60 |
def dechunk(
|
61 |
cls,
|
lib/extractor/ntr_text_fragmentation/chunking/specific_strategies/fixed_size_chunking.py
CHANGED
@@ -69,6 +69,12 @@ class FixedSizeChunkingStrategy(ChunkingStrategy):
|
|
69 |
self._re_space_newline = re.compile(r' +\n')
|
70 |
self._re_newline_space = re.compile(r'\n +')
|
71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
def chunk(
|
73 |
self, document: ParsedDocument, doc_entity: DocumentAsEntity
|
74 |
) -> list[LinkerEntity]:
|
|
|
69 |
self._re_space_newline = re.compile(r' +\n')
|
70 |
self._re_newline_space = re.compile(r'\n +')
|
71 |
|
72 |
+
async def chunk_async(
|
73 |
+
self, document: ParsedDocument, doc_entity: DocumentAsEntity
|
74 |
+
) -> list[LinkerEntity]:
|
75 |
+
"""Асинхронное разбиение документа на чанки."""
|
76 |
+
return self.chunk(document, doc_entity)
|
77 |
+
|
78 |
def chunk(
|
79 |
self, document: ParsedDocument, doc_entity: DocumentAsEntity
|
80 |
) -> list[LinkerEntity]:
|
lib/extractor/ntr_text_fragmentation/chunking/text_to_text_base.py
CHANGED
@@ -2,9 +2,9 @@ from abc import abstractmethod
|
|
2 |
|
3 |
from ntr_fileparser import ParsedDocument
|
4 |
|
5 |
-
from ..models import
|
6 |
-
from .models import CustomChunk
|
7 |
from .chunking_strategy import ChunkingStrategy
|
|
|
8 |
|
9 |
|
10 |
class TextToTextBaseStrategy(ChunkingStrategy):
|
@@ -15,10 +15,29 @@ class TextToTextBaseStrategy(ChunkingStrategy):
|
|
15 |
"""
|
16 |
|
17 |
def chunk(
|
18 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
) -> list[LinkerEntity]:
|
20 |
text = self._get_text(document)
|
21 |
-
texts = self.
|
22 |
return [
|
23 |
CustomChunk(
|
24 |
text=chunk_text,
|
@@ -39,7 +58,13 @@ class TextToTextBaseStrategy(ChunkingStrategy):
|
|
39 |
)
|
40 |
|
41 |
@abstractmethod
|
42 |
-
def _chunk(self, text: str
|
43 |
raise NotImplementedError(
|
44 |
"Метод _chunk должен быть реализован в классе-наследнике"
|
45 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
from ntr_fileparser import ParsedDocument
|
4 |
|
5 |
+
from ..models import DocumentAsEntity, LinkerEntity
|
|
|
6 |
from .chunking_strategy import ChunkingStrategy
|
7 |
+
from .models import CustomChunk
|
8 |
|
9 |
|
10 |
class TextToTextBaseStrategy(ChunkingStrategy):
|
|
|
15 |
"""
|
16 |
|
17 |
def chunk(
|
18 |
+
self,
|
19 |
+
document: ParsedDocument,
|
20 |
+
doc_entity: DocumentAsEntity,
|
21 |
+
) -> list[LinkerEntity]:
|
22 |
+
text = self._get_text(document)
|
23 |
+
texts = self._chunk(text)
|
24 |
+
return [
|
25 |
+
CustomChunk(
|
26 |
+
text=chunk_text,
|
27 |
+
in_search_text=chunk_text,
|
28 |
+
number_in_relation=i,
|
29 |
+
groupper=self.__class__.__name__,
|
30 |
+
)
|
31 |
+
for i, chunk_text in enumerate(texts)
|
32 |
+
]
|
33 |
+
|
34 |
+
async def chunk_async(
|
35 |
+
self,
|
36 |
+
document: ParsedDocument,
|
37 |
+
doc_entity: DocumentAsEntity,
|
38 |
) -> list[LinkerEntity]:
|
39 |
text = self._get_text(document)
|
40 |
+
texts = await self._chunk_async(text)
|
41 |
return [
|
42 |
CustomChunk(
|
43 |
text=chunk_text,
|
|
|
58 |
)
|
59 |
|
60 |
@abstractmethod
|
61 |
+
def _chunk(self, text: str) -> list[str]:
|
62 |
raise NotImplementedError(
|
63 |
"Метод _chunk должен быть реализован в классе-наследнике"
|
64 |
)
|
65 |
+
|
66 |
+
@abstractmethod
|
67 |
+
async def _chunk_async(self, text: str) -> list[str]:
|
68 |
+
raise NotImplementedError(
|
69 |
+
"Метод _chunk_async должен быть реализован в классе-наследнике, если используется chunk_async"
|
70 |
+
)
|
lib/extractor/ntr_text_fragmentation/core/extractor.py
CHANGED
@@ -3,13 +3,13 @@
|
|
3 |
"""
|
4 |
|
5 |
import logging
|
6 |
-
from typing import Any
|
7 |
from uuid import uuid4
|
8 |
|
9 |
from ntr_fileparser import ParsedDocument, ParsedTextBlock
|
10 |
|
11 |
from ..additors import TablesProcessor
|
12 |
-
from ..chunking import
|
13 |
from ..models import DocumentAsEntity, LinkerEntity
|
14 |
|
15 |
|
@@ -27,6 +27,7 @@ class EntitiesExtractor:
|
|
27 |
Координирует разбиение документа на чанки и обработку
|
28 |
дополнительных сущностей (например, таблиц) с использованием
|
29 |
зарегистрированных стратегий и процессоров.
|
|
|
30 |
"""
|
31 |
|
32 |
def __init__(
|
@@ -129,25 +130,26 @@ class EntitiesExtractor:
|
|
129 |
Returns:
|
130 |
Destructurer: Возвращает сам себя для удобства использования в цепочке вызовов
|
131 |
"""
|
132 |
-
self.tables_processor = TablesProcessor()
|
133 |
logger.info(f"Процессор таблиц установлен: {process_tables}")
|
134 |
return self
|
135 |
|
136 |
def extract(self, document: ParsedDocument | str) -> list[LinkerEntity]:
|
137 |
"""
|
138 |
-
|
139 |
-
Чанкает и извлекает из документа всё, что можно из него
|
140 |
-
|
|
|
141 |
|
142 |
Args:
|
143 |
-
document: Документ для извлечения информации. Если передать строку, она будет
|
144 |
автоматически преобразована в `ParsedDocument`
|
145 |
|
146 |
Returns:
|
147 |
list[LinkerEntity]: список сущностей (документ, чанки, таблицы, связи)
|
148 |
|
149 |
Raises:
|
150 |
-
|
151 |
"""
|
152 |
if isinstance(document, str):
|
153 |
document = ParsedDocument(
|
@@ -164,7 +166,7 @@ class EntitiesExtractor:
|
|
164 |
|
165 |
if self.strategy is not None:
|
166 |
logger.info(
|
167 |
-
f"
|
168 |
)
|
169 |
entities += self._chunk(document, doc_entity)
|
170 |
|
@@ -172,7 +174,53 @@ class EntitiesExtractor:
|
|
172 |
logger.info(f"Обработка таблиц в документе {document.name}...")
|
173 |
entities += self.tables_processor.extract(document, doc_entity)
|
174 |
|
175 |
-
logger.info(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
entities = [entity.serialize() for entity in entities]
|
177 |
|
178 |
return entities
|
@@ -186,9 +234,19 @@ class EntitiesExtractor:
|
|
186 |
raise RuntimeError("Стратегия чанкинга не выставлена")
|
187 |
|
188 |
doc_entity.chunking_strategy_ref = self._strategy_name
|
189 |
-
|
190 |
return self.strategy.chunk(document, doc_entity)
|
191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
192 |
def _create_document_entity(self, document: ParsedDocument) -> DocumentAsEntity:
|
193 |
"""
|
194 |
Создает сущность документа.
|
|
|
3 |
"""
|
4 |
|
5 |
import logging
|
6 |
+
from typing import Any
|
7 |
from uuid import uuid4
|
8 |
|
9 |
from ntr_fileparser import ParsedDocument, ParsedTextBlock
|
10 |
|
11 |
from ..additors import TablesProcessor
|
12 |
+
from ..chunking import FIXED_SIZE, ChunkingStrategy, chunking_registry
|
13 |
from ..models import DocumentAsEntity, LinkerEntity
|
14 |
|
15 |
|
|
|
27 |
Координирует разбиение документа на чанки и обработку
|
28 |
дополнительных сущностей (например, таблиц) с использованием
|
29 |
зарегистрированных стратегий и процессоров.
|
30 |
+
Имеет синхронный (`extract`) и асинхронный (`extract_async`) методы.
|
31 |
"""
|
32 |
|
33 |
def __init__(
|
|
|
130 |
Returns:
|
131 |
Destructurer: Возвращает сам себя для удобства использования в цепочке вызовов
|
132 |
"""
|
133 |
+
self.tables_processor = TablesProcessor() if process_tables else None
|
134 |
logger.info(f"Процессор таблиц установлен: {process_tables}")
|
135 |
return self
|
136 |
|
137 |
def extract(self, document: ParsedDocument | str) -> list[LinkerEntity]:
|
138 |
"""
|
139 |
+
Синхронный метод извлечения информации из документа.
|
140 |
+
Чанкает и извлекает из документа всё, что можно из него извлечь,
|
141 |
+
используя синхронные методы стратегий.
|
142 |
+
Если стратегия не поддерживает синхронный вызов, будет вызвано исключение.
|
143 |
|
144 |
Args:
|
145 |
+
document: Документ для извлечения информации. Если передать строку, она будет
|
146 |
автоматически преобразована в `ParsedDocument`
|
147 |
|
148 |
Returns:
|
149 |
list[LinkerEntity]: список сущностей (документ, чанки, таблицы, связи)
|
150 |
|
151 |
Raises:
|
152 |
+
NotImplementedError: Если выбранная стратегия не поддерживает синхронный вызов.
|
153 |
"""
|
154 |
if isinstance(document, str):
|
155 |
document = ParsedDocument(
|
|
|
166 |
|
167 |
if self.strategy is not None:
|
168 |
logger.info(
|
169 |
+
f"Синхронное чанкирование документа {document.name} с помощью стратегии {self.strategy.__class__.__name__}..."
|
170 |
)
|
171 |
entities += self._chunk(document, doc_entity)
|
172 |
|
|
|
174 |
logger.info(f"Обработка таблиц в документе {document.name}...")
|
175 |
entities += self.tables_processor.extract(document, doc_entity)
|
176 |
|
177 |
+
logger.info(f"Синхронное извлечение информации из документа {document.name} завершено.")
|
178 |
+
entities = [entity.serialize() for entity in entities]
|
179 |
+
|
180 |
+
return entities
|
181 |
+
|
182 |
+
async def extract_async(self, document: ParsedDocument | str) -> list[LinkerEntity]:
|
183 |
+
"""
|
184 |
+
Асинхронный метод извлечения информации из документа.
|
185 |
+
Чанкает и извлекает из документа всё, что можно из него извлечь,
|
186 |
+
используя асинхронные методы стратегий там, где они доступны.
|
187 |
+
|
188 |
+
Args:
|
189 |
+
document: Документ для извлечения информации. Если передать строку, она будет
|
190 |
+
автоматически преобразована в `ParsedDocument`
|
191 |
+
|
192 |
+
Returns:
|
193 |
+
list[LinkerEntity]: список сущностей (документ, чанки, таблицы, связи)
|
194 |
+
|
195 |
+
Raises:
|
196 |
+
RuntimeError: Если стратегия не была сконфигурирована.
|
197 |
+
"""
|
198 |
+
if isinstance(document, str):
|
199 |
+
document = ParsedDocument(
|
200 |
+
name='unknown',
|
201 |
+
type='PlainText',
|
202 |
+
paragraphs=[
|
203 |
+
ParsedTextBlock(text=paragraph)
|
204 |
+
for paragraph in document.split('\n')
|
205 |
+
],
|
206 |
+
)
|
207 |
+
|
208 |
+
doc_entity = self._create_document_entity(document)
|
209 |
+
entities: list[LinkerEntity] = [doc_entity]
|
210 |
+
|
211 |
+
if self.strategy is not None:
|
212 |
+
logger.info(
|
213 |
+
f"Асинхронное чанкирование документа {document.name} с помощью стратегии {self.strategy.__class__.__name__}..."
|
214 |
+
)
|
215 |
+
chunk_entities = await self._chunk_async(document, doc_entity)
|
216 |
+
entities.extend(chunk_entities)
|
217 |
+
|
218 |
+
if self.tables_processor is not None:
|
219 |
+
logger.info(f"Обработка таблиц в документе {document.name}...")
|
220 |
+
table_entities = self.tables_processor.extract(document, doc_entity)
|
221 |
+
entities.extend(table_entities)
|
222 |
+
|
223 |
+
logger.info(f"Асинхронное извлечение информации из документа {document.name} завершено.")
|
224 |
entities = [entity.serialize() for entity in entities]
|
225 |
|
226 |
return entities
|
|
|
234 |
raise RuntimeError("Стратегия чанкинга не выставлена")
|
235 |
|
236 |
doc_entity.chunking_strategy_ref = self._strategy_name
|
|
|
237 |
return self.strategy.chunk(document, doc_entity)
|
238 |
|
239 |
+
async def _chunk_async(
|
240 |
+
self,
|
241 |
+
document: ParsedDocument,
|
242 |
+
doc_entity: DocumentAsEntity,
|
243 |
+
) -> list[LinkerEntity]:
|
244 |
+
if self.strategy is None:
|
245 |
+
raise RuntimeError("Стратегия чанкинга не выставлена")
|
246 |
+
|
247 |
+
doc_entity.chunking_strategy_ref = self._strategy_name
|
248 |
+
return await self.strategy.chunk_async(document, doc_entity)
|
249 |
+
|
250 |
def _create_document_entity(self, document: ParsedDocument) -> DocumentAsEntity:
|
251 |
"""
|
252 |
Создает сущность документа.
|
routes/llm.py
CHANGED
@@ -14,7 +14,7 @@ from components.llm.common import (ChatRequest, LlmParams, LlmPredictParams,
|
|
14 |
from components.llm.deepinfra_api import DeepInfraApi
|
15 |
from components.llm.utils import append_llm_response_to_history
|
16 |
from components.services.dataset import DatasetService
|
17 |
-
from components.services.dialogue import DialogueService
|
18 |
from components.services.entity import EntityService
|
19 |
from components.services.llm_config import LLMConfigService
|
20 |
from components.services.llm_prompt import LlmPromptService
|
@@ -121,13 +121,13 @@ async def sse_generator(request: ChatRequest, llm_api: DeepInfraApi, system_prom
|
|
121 |
"""
|
122 |
Генератор для стриминга ответа LLM через SSE.
|
123 |
"""
|
124 |
-
qe_result = None
|
125 |
try:
|
126 |
qe_result = await dialogue_service.get_qe_result(request.history)
|
127 |
|
128 |
except Exception as e:
|
129 |
logger.error(f"Error in SSE chat stream while dialogue_service.get_qe_result: {str(e)}", stack_info=True)
|
130 |
yield "data: {\"event\": \"error\", \"data\":\""+str(e)+"\" }\n\n"
|
|
|
131 |
|
132 |
try:
|
133 |
if qe_result.use_search and qe_result.search_query is not None:
|
@@ -241,7 +241,12 @@ async def chat(
|
|
241 |
stop=[],
|
242 |
)
|
243 |
|
244 |
-
|
|
|
|
|
|
|
|
|
|
|
245 |
last_message = get_last_user_message(request)
|
246 |
|
247 |
logger.info(f"qe_result: {qe_result}")
|
|
|
14 |
from components.llm.deepinfra_api import DeepInfraApi
|
15 |
from components.llm.utils import append_llm_response_to_history
|
16 |
from components.services.dataset import DatasetService
|
17 |
+
from components.services.dialogue import DialogueService, QEResult
|
18 |
from components.services.entity import EntityService
|
19 |
from components.services.llm_config import LLMConfigService
|
20 |
from components.services.llm_prompt import LlmPromptService
|
|
|
121 |
"""
|
122 |
Генератор для стриминга ответа LLM через SSE.
|
123 |
"""
|
|
|
124 |
try:
|
125 |
qe_result = await dialogue_service.get_qe_result(request.history)
|
126 |
|
127 |
except Exception as e:
|
128 |
logger.error(f"Error in SSE chat stream while dialogue_service.get_qe_result: {str(e)}", stack_info=True)
|
129 |
yield "data: {\"event\": \"error\", \"data\":\""+str(e)+"\" }\n\n"
|
130 |
+
qe_result = dialogue_service.get_qe_result_from_chat(request.history)
|
131 |
|
132 |
try:
|
133 |
if qe_result.use_search and qe_result.search_query is not None:
|
|
|
241 |
stop=[],
|
242 |
)
|
243 |
|
244 |
+
try:
|
245 |
+
qe_result = await dialogue_service.get_qe_result(request.history)
|
246 |
+
except Exception as e:
|
247 |
+
logger.error(f"Error in chat while dialogue_service.get_qe_result: {str(e)}", stack_info=True)
|
248 |
+
qe_result = dialogue_service.get_qe_result_from_chat(request.history)
|
249 |
+
|
250 |
last_message = get_last_user_message(request)
|
251 |
|
252 |
logger.info(f"qe_result: {qe_result}")
|