smart-bot-factory 0.3.6__py3-none-any.whl → 0.3.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of smart-bot-factory might be problematic. Click here for more details.
- smart_bot_factory/admin/__init__.py +7 -7
- smart_bot_factory/admin/admin_events.py +483 -383
- smart_bot_factory/admin/admin_logic.py +234 -158
- smart_bot_factory/admin/admin_manager.py +68 -53
- smart_bot_factory/admin/admin_tester.py +46 -40
- smart_bot_factory/admin/timeout_checker.py +201 -153
- smart_bot_factory/aiogram_calendar/__init__.py +11 -3
- smart_bot_factory/aiogram_calendar/common.py +12 -18
- smart_bot_factory/aiogram_calendar/dialog_calendar.py +126 -64
- smart_bot_factory/aiogram_calendar/schemas.py +49 -28
- smart_bot_factory/aiogram_calendar/simple_calendar.py +94 -50
- smart_bot_factory/analytics/analytics_manager.py +414 -392
- smart_bot_factory/cli.py +204 -148
- smart_bot_factory/config.py +123 -102
- smart_bot_factory/core/bot_utils.py +480 -324
- smart_bot_factory/core/conversation_manager.py +287 -200
- smart_bot_factory/core/decorators.py +1145 -739
- smart_bot_factory/core/message_sender.py +287 -266
- smart_bot_factory/core/router.py +170 -100
- smart_bot_factory/core/router_manager.py +121 -83
- smart_bot_factory/core/states.py +4 -3
- smart_bot_factory/creation/__init__.py +1 -1
- smart_bot_factory/creation/bot_builder.py +320 -242
- smart_bot_factory/creation/bot_testing.py +440 -365
- smart_bot_factory/dashboard/__init__.py +1 -3
- smart_bot_factory/event/__init__.py +2 -7
- smart_bot_factory/handlers/handlers.py +682 -466
- smart_bot_factory/integrations/openai_client.py +218 -168
- smart_bot_factory/integrations/supabase_client.py +928 -637
- smart_bot_factory/message/__init__.py +18 -22
- smart_bot_factory/router/__init__.py +2 -2
- smart_bot_factory/setup_checker.py +162 -126
- smart_bot_factory/supabase/__init__.py +1 -1
- smart_bot_factory/supabase/client.py +631 -515
- smart_bot_factory/utils/__init__.py +2 -3
- smart_bot_factory/utils/debug_routing.py +38 -27
- smart_bot_factory/utils/prompt_loader.py +153 -120
- smart_bot_factory/utils/user_prompt_loader.py +55 -56
- smart_bot_factory/utm_link_generator.py +123 -116
- {smart_bot_factory-0.3.6.dist-info → smart_bot_factory-0.3.8.dist-info}/METADATA +3 -1
- smart_bot_factory-0.3.8.dist-info/RECORD +59 -0
- smart_bot_factory-0.3.6.dist-info/RECORD +0 -59
- {smart_bot_factory-0.3.6.dist-info → smart_bot_factory-0.3.8.dist-info}/WHEEL +0 -0
- {smart_bot_factory-0.3.6.dist-info → smart_bot_factory-0.3.8.dist-info}/entry_points.txt +0 -0
- {smart_bot_factory-0.3.6.dist-info → smart_bot_factory-0.3.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,88 +1,94 @@
|
|
|
1
|
-
import logging
|
|
2
1
|
import asyncio
|
|
3
|
-
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
4
5
|
from openai import AsyncOpenAI
|
|
5
6
|
from openai.types.chat import ChatCompletion
|
|
6
7
|
|
|
7
8
|
logger = logging.getLogger(__name__)
|
|
8
9
|
|
|
10
|
+
|
|
9
11
|
class OpenAIClient:
|
|
10
12
|
"""Клиент для работы с OpenAI API"""
|
|
11
|
-
|
|
13
|
+
|
|
12
14
|
def __init__(
|
|
13
|
-
self,
|
|
14
|
-
api_key: str,
|
|
15
|
-
model: str = "gpt-4",
|
|
15
|
+
self,
|
|
16
|
+
api_key: str,
|
|
17
|
+
model: str = "gpt-4",
|
|
16
18
|
max_tokens: int = 1500,
|
|
17
|
-
temperature: float = 0.7
|
|
19
|
+
temperature: float = 0.7,
|
|
18
20
|
):
|
|
19
21
|
self.client = AsyncOpenAI(api_key=api_key)
|
|
20
22
|
self.model = model
|
|
21
23
|
self.max_tokens = max_tokens
|
|
22
24
|
self.temperature = temperature
|
|
23
|
-
|
|
25
|
+
|
|
24
26
|
# Настройки для повторных попыток
|
|
25
27
|
self.max_retries = 3
|
|
26
28
|
self.retry_delay = 1 # секунды
|
|
27
|
-
|
|
29
|
+
|
|
28
30
|
# Определяем, является ли это GPT-5 моделью
|
|
29
|
-
self.is_gpt5 =
|
|
30
|
-
|
|
31
|
+
self.is_gpt5 = "gpt-5" in model.lower()
|
|
32
|
+
|
|
31
33
|
# Получаем лимиты для модели
|
|
32
34
|
self.model_limits = self._get_model_limits()
|
|
33
|
-
|
|
35
|
+
|
|
34
36
|
# 🆕 Для диагностики пустых ответов
|
|
35
37
|
self.last_completion_tokens = 0
|
|
36
|
-
|
|
37
|
-
logger.info(
|
|
38
|
-
|
|
38
|
+
|
|
39
|
+
logger.info(
|
|
40
|
+
f"OpenAI клиент инициализирован с моделью {model} (GPT-5: {self.is_gpt5}, лимит: {self.model_limits['total_context']} токенов)"
|
|
41
|
+
)
|
|
42
|
+
|
|
39
43
|
def _get_model_limits(self) -> Dict[str, int]:
|
|
40
44
|
"""Возвращает лимиты для конкретной модели"""
|
|
41
45
|
model_limits = {
|
|
42
46
|
# GPT-3.5
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
"gpt-3.5-turbo": 4096,
|
|
48
|
+
"gpt-3.5-turbo-16k": 16385,
|
|
46
49
|
# GPT-4
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
"gpt-4": 8192,
|
|
51
|
+
"gpt-4-32k": 32768,
|
|
52
|
+
"gpt-4-turbo": 128000,
|
|
53
|
+
"gpt-4-turbo-preview": 128000,
|
|
54
|
+
"gpt-4o": 128000,
|
|
55
|
+
"gpt-4o-mini": 128000,
|
|
54
56
|
# GPT-5
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
"gpt-5-mini": 128000,
|
|
58
|
+
"gpt-5": 200000,
|
|
57
59
|
}
|
|
58
|
-
|
|
60
|
+
|
|
59
61
|
# Получаем лимит для текущей модели или используем консервативное значение
|
|
60
62
|
total_limit = model_limits.get(self.model, 8192)
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
# Резервируем место для ответа и буфера
|
|
63
|
-
completion_reserve = min(
|
|
65
|
+
completion_reserve = min(
|
|
66
|
+
self.max_tokens * 2, total_limit // 4
|
|
67
|
+
) # Резервируем место для ответа
|
|
64
68
|
buffer_reserve = 500 # Буфер для безопасности
|
|
65
|
-
|
|
69
|
+
|
|
66
70
|
return {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
"total_context": total_limit,
|
|
72
|
+
"max_input_tokens": total_limit - completion_reserve - buffer_reserve,
|
|
73
|
+
"completion_reserve": completion_reserve,
|
|
70
74
|
}
|
|
71
|
-
|
|
72
|
-
def _get_completion_params(
|
|
75
|
+
|
|
76
|
+
def _get_completion_params(
|
|
77
|
+
self, max_tokens: Optional[int] = None, temperature: Optional[float] = None
|
|
78
|
+
) -> Dict[str, Any]:
|
|
73
79
|
"""Возвращает параметры для создания completion в зависимости от модели"""
|
|
74
80
|
tokens = max_tokens or self.max_tokens
|
|
75
81
|
temp = temperature or self.temperature
|
|
76
|
-
|
|
82
|
+
|
|
77
83
|
params = {
|
|
78
84
|
"model": self.model,
|
|
79
85
|
}
|
|
80
|
-
|
|
86
|
+
|
|
81
87
|
# 🆕 ИСПРАВЛЕННАЯ ЛОГИКА ДЛЯ GPT-5
|
|
82
88
|
if self.is_gpt5:
|
|
83
89
|
# 🔧 ИСПОЛЬЗУЕМ ТОКЕНЫ ИЗ КОНФИГА (как настроил пользователь)
|
|
84
90
|
params["max_completion_tokens"] = tokens
|
|
85
|
-
|
|
91
|
+
|
|
86
92
|
# 🔧 БЫСТРЫЕ ОТВЕТЫ с минимальным reasoning
|
|
87
93
|
# Допустимые значения reasoning_effort:
|
|
88
94
|
# "minimal" - самые быстрые ответы, минимальное reasoning
|
|
@@ -90,236 +96,277 @@ class OpenAIClient:
|
|
|
90
96
|
# "medium" - сбалансированное reasoning (медленнее)
|
|
91
97
|
# "high" - максимальное reasoning (самые медленные, но качественные)
|
|
92
98
|
params["reasoning_effort"] = "minimal" # Быстрые ответы
|
|
93
|
-
|
|
94
|
-
logger.debug(
|
|
99
|
+
|
|
100
|
+
logger.debug(
|
|
101
|
+
f"GPT-5 параметры: max_completion_tokens={tokens}, reasoning_effort=minimal"
|
|
102
|
+
)
|
|
95
103
|
else:
|
|
96
104
|
# Для остальных моделей стандартные параметры
|
|
97
105
|
params["max_tokens"] = tokens
|
|
98
106
|
params["temperature"] = temp
|
|
99
|
-
|
|
107
|
+
|
|
100
108
|
return params
|
|
101
109
|
|
|
102
110
|
async def get_completion(
|
|
103
|
-
self,
|
|
104
|
-
messages: List[Dict[str, str]],
|
|
111
|
+
self,
|
|
112
|
+
messages: List[Dict[str, str]],
|
|
105
113
|
max_tokens: Optional[int] = None,
|
|
106
114
|
temperature: Optional[float] = None,
|
|
107
|
-
stream: bool = False
|
|
115
|
+
stream: bool = False,
|
|
108
116
|
) -> str:
|
|
109
117
|
"""Получает ответ от OpenAI API с поддержкой GPT-5"""
|
|
110
118
|
if not messages:
|
|
111
119
|
raise ValueError("Список сообщений не может быть пустым")
|
|
112
|
-
|
|
120
|
+
|
|
113
121
|
for attempt in range(self.max_retries):
|
|
114
122
|
try:
|
|
115
123
|
logger.debug(f"Отправка запроса к OpenAI (попытка {attempt + 1})")
|
|
116
|
-
|
|
124
|
+
|
|
117
125
|
# Обрезаем сообщения если контекст слишком большой
|
|
118
126
|
processed_messages = await self._prepare_messages(messages)
|
|
119
|
-
|
|
127
|
+
|
|
120
128
|
# Получаем параметры в зависимости от модели
|
|
121
129
|
params = self._get_completion_params(max_tokens, temperature)
|
|
122
130
|
params["messages"] = processed_messages
|
|
123
131
|
params["stream"] = stream
|
|
124
|
-
|
|
132
|
+
|
|
125
133
|
logger.info(f"🚀 Отправляем запрос к {self.model}")
|
|
126
134
|
logger.info(f"📝 Сообщений в контексте: {len(processed_messages)}")
|
|
127
|
-
|
|
135
|
+
|
|
128
136
|
# Логируем параметры в зависимости от модели
|
|
129
137
|
if self.is_gpt5:
|
|
130
|
-
max_tokens_param = params.get(
|
|
131
|
-
reasoning_effort = params.get(
|
|
132
|
-
logger.info(
|
|
138
|
+
max_tokens_param = params.get("max_completion_tokens")
|
|
139
|
+
reasoning_effort = params.get("reasoning_effort")
|
|
140
|
+
logger.info(
|
|
141
|
+
f"⚙️ GPT-5 параметры: max_completion_tokens={max_tokens_param}, reasoning_effort={reasoning_effort}"
|
|
142
|
+
)
|
|
133
143
|
else:
|
|
134
|
-
max_tokens_param = params.get(
|
|
135
|
-
temp_param = params.get(
|
|
136
|
-
logger.info(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
max_tokens_param = params.get("max_tokens")
|
|
145
|
+
temp_param = params.get("temperature")
|
|
146
|
+
logger.info(
|
|
147
|
+
f"⚙️ Стандартные параметры: max_tokens={max_tokens_param}, temp={temp_param}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
response: ChatCompletion = await self.client.chat.completions.create(
|
|
151
|
+
**params
|
|
152
|
+
)
|
|
153
|
+
|
|
140
154
|
if stream:
|
|
141
155
|
return await self._handle_stream_response(response)
|
|
142
156
|
else:
|
|
143
157
|
# 🔧 ПРАВИЛЬНЫЙ ПАРСИНГ ДЛЯ GPT-5 (может быть несколько choices)
|
|
144
158
|
if self.is_gpt5 and len(response.choices) > 1:
|
|
145
|
-
logger.info(
|
|
146
|
-
|
|
159
|
+
logger.info(
|
|
160
|
+
f"📊 GPT-5 вернул {len(response.choices)} choices, ищем пользовательский контент"
|
|
161
|
+
)
|
|
162
|
+
|
|
147
163
|
# Ищем choice с пользовательским контентом (не reasoning summary)
|
|
148
164
|
content = None
|
|
149
165
|
finish_reason = None
|
|
150
|
-
|
|
166
|
+
|
|
151
167
|
for i, choice in enumerate(response.choices):
|
|
152
168
|
choice_content = choice.message.content
|
|
153
169
|
choice_finish = choice.finish_reason
|
|
154
|
-
|
|
155
|
-
logger.info(
|
|
156
|
-
|
|
170
|
+
|
|
171
|
+
logger.info(
|
|
172
|
+
f" Choice {i}: content={len(choice_content) if choice_content else 0} chars, finish={choice_finish}"
|
|
173
|
+
)
|
|
174
|
+
|
|
157
175
|
# Берем первый choice с реальным контентом
|
|
158
176
|
if choice_content and choice_content.strip():
|
|
159
177
|
content = choice_content
|
|
160
178
|
finish_reason = choice_finish
|
|
161
|
-
logger.info(
|
|
179
|
+
logger.info(
|
|
180
|
+
f" ✅ Используем choice {i} как пользовательский контент"
|
|
181
|
+
)
|
|
162
182
|
break
|
|
163
|
-
|
|
183
|
+
|
|
164
184
|
# Если не нашли контент ни в одном choice
|
|
165
185
|
if not content:
|
|
166
|
-
logger.warning(
|
|
186
|
+
logger.warning(
|
|
187
|
+
" ❌ Не найден пользовательский контент ни в одном choice"
|
|
188
|
+
)
|
|
167
189
|
content = ""
|
|
168
190
|
finish_reason = response.choices[0].finish_reason
|
|
169
191
|
else:
|
|
170
192
|
# Стандартная обработка для не-GPT-5 или одного choice
|
|
171
193
|
finish_reason = response.choices[0].finish_reason
|
|
172
194
|
content = response.choices[0].message.content
|
|
173
|
-
|
|
174
|
-
logger.info(
|
|
195
|
+
|
|
196
|
+
logger.info("📊 OpenAI ответ получен:")
|
|
175
197
|
logger.info(f" 🏁 Finish reason: {finish_reason}")
|
|
176
198
|
logger.info(f" 🔤 Тип content: {type(content)}")
|
|
177
199
|
logger.info(f" 📏 Длина: {len(content) if content else 0}")
|
|
178
|
-
|
|
200
|
+
|
|
179
201
|
# Логируем использование токенов
|
|
180
202
|
if response.usage:
|
|
181
203
|
self.last_completion_tokens = response.usage.completion_tokens
|
|
182
|
-
|
|
183
|
-
logger.info(
|
|
204
|
+
|
|
205
|
+
logger.info("💰 Использование токенов:")
|
|
184
206
|
logger.info(f" 📥 Prompt: {response.usage.prompt_tokens}")
|
|
185
|
-
logger.info(
|
|
207
|
+
logger.info(
|
|
208
|
+
f" 📤 Completion: {response.usage.completion_tokens}"
|
|
209
|
+
)
|
|
186
210
|
logger.info(f" 🔢 Total: {response.usage.total_tokens}")
|
|
187
|
-
|
|
211
|
+
|
|
188
212
|
# 🆕 ЛОГИРУЕМ REASONING TOKENS ДЛЯ GPT-5
|
|
189
|
-
if hasattr(response.usage,
|
|
190
|
-
reasoning_tokens = getattr(
|
|
213
|
+
if hasattr(response.usage, "reasoning_tokens"):
|
|
214
|
+
reasoning_tokens = getattr(
|
|
215
|
+
response.usage, "reasoning_tokens", 0
|
|
216
|
+
)
|
|
191
217
|
if reasoning_tokens > 0:
|
|
192
218
|
logger.info(f" 💭 Reasoning: {reasoning_tokens}")
|
|
193
|
-
|
|
219
|
+
|
|
194
220
|
# 🔧 ИСПРАВЛЕННАЯ ОБРАБОТКА GPT-5
|
|
195
221
|
if self.is_gpt5:
|
|
196
222
|
# Проверяем, есть ли reasoning токены
|
|
197
223
|
reasoning_tokens = 0
|
|
198
|
-
if response.usage and hasattr(
|
|
199
|
-
|
|
200
|
-
|
|
224
|
+
if response.usage and hasattr(
|
|
225
|
+
response.usage, "reasoning_tokens"
|
|
226
|
+
):
|
|
227
|
+
reasoning_tokens = getattr(
|
|
228
|
+
response.usage, "reasoning_tokens", 0
|
|
229
|
+
)
|
|
230
|
+
|
|
201
231
|
# Если есть reasoning но нет content, увеличиваем токены
|
|
202
|
-
if reasoning_tokens > 0 and (
|
|
203
|
-
|
|
204
|
-
|
|
232
|
+
if reasoning_tokens > 0 and (
|
|
233
|
+
not content or not content.strip()
|
|
234
|
+
):
|
|
235
|
+
logger.warning(
|
|
236
|
+
f"🔧 GPT-5: reasoning_tokens={reasoning_tokens}, но content пустой"
|
|
237
|
+
)
|
|
238
|
+
|
|
205
239
|
# Если это не последняя попытка, пробуем с большим количеством токенов
|
|
206
240
|
if attempt < self.max_retries - 1:
|
|
207
241
|
current_tokens = max_tokens or self.max_tokens
|
|
208
|
-
increased_tokens = min(
|
|
209
|
-
|
|
210
|
-
|
|
242
|
+
increased_tokens = min(
|
|
243
|
+
current_tokens * 2, 8000
|
|
244
|
+
) # Удваиваем, максимум 8000
|
|
245
|
+
|
|
246
|
+
logger.info(
|
|
247
|
+
f"🔄 Пробуем с увеличенными токенами: {increased_tokens}"
|
|
248
|
+
)
|
|
211
249
|
return await self.get_completion(
|
|
212
|
-
messages,
|
|
213
|
-
max_tokens=increased_tokens,
|
|
214
|
-
temperature=temperature,
|
|
215
|
-
stream=stream
|
|
250
|
+
messages,
|
|
251
|
+
max_tokens=increased_tokens,
|
|
252
|
+
temperature=temperature,
|
|
253
|
+
stream=stream,
|
|
216
254
|
)
|
|
217
255
|
else:
|
|
218
256
|
# Последняя попытка - возвращаем информативное сообщение
|
|
219
257
|
return "Извините, модель потратила время на анализ, но не смогла сформулировать ответ. Попробуйте переформулировать вопрос."
|
|
220
|
-
|
|
258
|
+
|
|
221
259
|
# Обычная обработка finish_reason
|
|
222
|
-
if finish_reason ==
|
|
223
|
-
logger.warning(
|
|
224
|
-
|
|
260
|
+
if finish_reason == "length":
|
|
261
|
+
logger.warning("⚠️ Модель достигла лимита токенов")
|
|
262
|
+
|
|
225
263
|
if content and content.strip():
|
|
226
264
|
# Есть частичный контент - возвращаем его
|
|
227
265
|
return content
|
|
228
266
|
else:
|
|
229
267
|
# Нет контента - информируем пользователя
|
|
230
268
|
return "Ответ слишком длинный. Попробуйте задать более конкретный вопрос."
|
|
231
|
-
|
|
232
|
-
elif finish_reason ==
|
|
233
|
-
logger.info(
|
|
234
|
-
|
|
235
|
-
elif finish_reason ==
|
|
236
|
-
logger.warning(
|
|
269
|
+
|
|
270
|
+
elif finish_reason == "stop":
|
|
271
|
+
logger.info("✅ Нормальное завершение")
|
|
272
|
+
|
|
273
|
+
elif finish_reason == "content_filter":
|
|
274
|
+
logger.warning("🚫 Ответ заблокирован фильтром контента")
|
|
237
275
|
return "Извините, не могу ответить на этот вопрос."
|
|
238
|
-
|
|
276
|
+
|
|
239
277
|
# Детальное логирование содержимого
|
|
240
278
|
if content:
|
|
241
279
|
logger.info(f" 🔍 Начало (50 символов): '{content[:50]}'")
|
|
242
280
|
logger.info(f" 🔍 Конец (50 символов): '{content[-50:]}'")
|
|
243
281
|
else:
|
|
244
|
-
logger.warning(
|
|
245
|
-
|
|
282
|
+
logger.warning(" ❌ content is None или пустой")
|
|
283
|
+
|
|
246
284
|
return content or ""
|
|
247
|
-
|
|
285
|
+
|
|
248
286
|
except Exception as e:
|
|
249
|
-
logger.warning(
|
|
250
|
-
|
|
287
|
+
logger.warning(
|
|
288
|
+
f"Ошибка при обращении к OpenAI (попытка {attempt + 1}): {e}"
|
|
289
|
+
)
|
|
290
|
+
|
|
251
291
|
if attempt == self.max_retries - 1:
|
|
252
292
|
logger.error(f"Все попытки исчерпаны. Последняя ошибка: {e}")
|
|
253
293
|
raise
|
|
254
|
-
|
|
294
|
+
|
|
255
295
|
await asyncio.sleep(self.retry_delay * (attempt + 1))
|
|
256
|
-
|
|
296
|
+
|
|
257
297
|
raise Exception("Не удалось получить ответ от OpenAI")
|
|
258
|
-
|
|
259
|
-
async def _prepare_messages(
|
|
298
|
+
|
|
299
|
+
async def _prepare_messages(
|
|
300
|
+
self, messages: List[Dict[str, str]]
|
|
301
|
+
) -> List[Dict[str, str]]:
|
|
260
302
|
"""
|
|
261
303
|
Подготавливает сообщения для отправки в API
|
|
262
304
|
Обрезает контекст если он слишком большой
|
|
263
305
|
"""
|
|
306
|
+
|
|
264
307
|
# Более точная оценка токенов для русского текста
|
|
265
308
|
def estimate_message_tokens(msg):
|
|
266
|
-
content = msg.get(
|
|
309
|
+
content = msg.get("content", "")
|
|
267
310
|
# Для русского текста: примерно 2.5-3 символа на токен
|
|
268
311
|
return len(content) // 2.5
|
|
269
|
-
|
|
312
|
+
|
|
270
313
|
total_estimated_tokens = sum(estimate_message_tokens(msg) for msg in messages)
|
|
271
|
-
max_input_tokens = self.model_limits[
|
|
272
|
-
|
|
314
|
+
max_input_tokens = self.model_limits["max_input_tokens"]
|
|
315
|
+
|
|
273
316
|
if total_estimated_tokens <= max_input_tokens:
|
|
274
317
|
return messages
|
|
275
|
-
|
|
276
|
-
logger.info(
|
|
277
|
-
|
|
318
|
+
|
|
319
|
+
logger.info(
|
|
320
|
+
f"Контекст слишком большой ({int(total_estimated_tokens)} токенов), обрезаем до {max_input_tokens}"
|
|
321
|
+
)
|
|
322
|
+
|
|
278
323
|
# Сохраняем системные сообщения
|
|
279
|
-
system_messages = [msg for msg in messages if msg.get(
|
|
280
|
-
other_messages = [msg for msg in messages if msg.get(
|
|
281
|
-
|
|
324
|
+
system_messages = [msg for msg in messages if msg.get("role") == "system"]
|
|
325
|
+
other_messages = [msg for msg in messages if msg.get("role") != "system"]
|
|
326
|
+
|
|
282
327
|
# Рассчитываем токены системных сообщений
|
|
283
328
|
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
|
284
329
|
available_tokens = max_input_tokens - system_tokens
|
|
285
|
-
|
|
330
|
+
|
|
286
331
|
if available_tokens <= 0:
|
|
287
332
|
logger.warning("Системные сообщения занимают весь доступный контекст")
|
|
288
333
|
return system_messages
|
|
289
|
-
|
|
334
|
+
|
|
290
335
|
# Берем последние сообщения, помещающиеся в доступные токены
|
|
291
336
|
current_tokens = 0
|
|
292
337
|
trimmed_messages = []
|
|
293
|
-
|
|
338
|
+
|
|
294
339
|
for msg in reversed(other_messages):
|
|
295
340
|
msg_tokens = estimate_message_tokens(msg)
|
|
296
341
|
if current_tokens + msg_tokens > available_tokens:
|
|
297
342
|
break
|
|
298
343
|
trimmed_messages.insert(0, msg)
|
|
299
344
|
current_tokens += msg_tokens
|
|
300
|
-
|
|
345
|
+
|
|
301
346
|
result_messages = system_messages + trimmed_messages
|
|
302
|
-
logger.info(
|
|
303
|
-
|
|
347
|
+
logger.info(
|
|
348
|
+
f"Контекст обрезан до {len(result_messages)} сообщений (~{int(current_tokens + system_tokens)} токенов)"
|
|
349
|
+
)
|
|
350
|
+
|
|
304
351
|
return result_messages
|
|
305
|
-
|
|
352
|
+
|
|
306
353
|
async def _handle_stream_response(self, response) -> str:
|
|
307
354
|
"""Обрабатывает потоковый ответ"""
|
|
308
355
|
full_content = ""
|
|
309
|
-
|
|
356
|
+
|
|
310
357
|
async for chunk in response:
|
|
311
358
|
if chunk.choices[0].delta.content is not None:
|
|
312
359
|
full_content += chunk.choices[0].delta.content
|
|
313
|
-
|
|
360
|
+
|
|
314
361
|
return full_content
|
|
315
|
-
|
|
362
|
+
|
|
316
363
|
async def analyze_sentiment(self, text: str) -> Dict[str, Any]:
|
|
317
364
|
"""
|
|
318
365
|
Анализирует настроение и намерения сообщения
|
|
319
|
-
|
|
366
|
+
|
|
320
367
|
Args:
|
|
321
368
|
text: Текст для анализа
|
|
322
|
-
|
|
369
|
+
|
|
323
370
|
Returns:
|
|
324
371
|
Словарь с результатами анализа
|
|
325
372
|
"""
|
|
@@ -343,20 +390,27 @@ class OpenAIClient:
|
|
|
343
390
|
"response_strategy": "рекомендуемая стратегия"
|
|
344
391
|
}}
|
|
345
392
|
"""
|
|
346
|
-
|
|
393
|
+
|
|
347
394
|
try:
|
|
348
395
|
# Для анализа настроения используем более низкую температуру если поддерживается
|
|
349
396
|
temp = 0.3 if not self.is_gpt5 else None
|
|
350
|
-
|
|
351
|
-
response = await self.get_completion(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
397
|
+
|
|
398
|
+
response = await self.get_completion(
|
|
399
|
+
[
|
|
400
|
+
{
|
|
401
|
+
"role": "system",
|
|
402
|
+
"content": "Ты эксперт по анализу намерений клиентов в продажах.",
|
|
403
|
+
},
|
|
404
|
+
{"role": "user", "content": analysis_prompt},
|
|
405
|
+
],
|
|
406
|
+
temperature=temp,
|
|
407
|
+
)
|
|
408
|
+
|
|
356
409
|
# Пытаемся распарсить JSON
|
|
357
410
|
import json
|
|
411
|
+
|
|
358
412
|
return json.loads(response)
|
|
359
|
-
|
|
413
|
+
|
|
360
414
|
except Exception as e:
|
|
361
415
|
logger.error(f"Ошибка при анализе настроения: {e}")
|
|
362
416
|
# Возвращаем дефолтные значения
|
|
@@ -366,21 +420,19 @@ class OpenAIClient:
|
|
|
366
420
|
"purchase_readiness": 5,
|
|
367
421
|
"objections": [],
|
|
368
422
|
"key_questions": [],
|
|
369
|
-
"response_strategy": "continue_conversation"
|
|
423
|
+
"response_strategy": "continue_conversation",
|
|
370
424
|
}
|
|
371
|
-
|
|
425
|
+
|
|
372
426
|
async def generate_follow_up(
|
|
373
|
-
self,
|
|
374
|
-
conversation_history: List[Dict[str, str]],
|
|
375
|
-
analysis: Dict[str, Any]
|
|
427
|
+
self, conversation_history: List[Dict[str, str]], analysis: Dict[str, Any]
|
|
376
428
|
) -> str:
|
|
377
429
|
"""
|
|
378
430
|
Генерирует персонализированное продолжение разговора
|
|
379
|
-
|
|
431
|
+
|
|
380
432
|
Args:
|
|
381
433
|
conversation_history: История разговора
|
|
382
434
|
analysis: Результат анализа последнего сообщения
|
|
383
|
-
|
|
435
|
+
|
|
384
436
|
Returns:
|
|
385
437
|
Персонализированный ответ
|
|
386
438
|
"""
|
|
@@ -398,66 +450,64 @@ class OpenAIClient:
|
|
|
398
450
|
3. Мягко направляет к следующему этапу воронки продаж
|
|
399
451
|
4. Сохраняет доверительный тон общения
|
|
400
452
|
"""
|
|
401
|
-
|
|
453
|
+
|
|
402
454
|
messages = conversation_history + [
|
|
403
455
|
{"role": "system", "content": strategy_prompt}
|
|
404
456
|
]
|
|
405
|
-
|
|
457
|
+
|
|
406
458
|
# Для творческих задач используем более высокую температуру если поддерживается
|
|
407
459
|
temp = 0.8 if not self.is_gpt5 else None
|
|
408
|
-
|
|
460
|
+
|
|
409
461
|
return await self.get_completion(messages, temperature=temp)
|
|
410
|
-
|
|
462
|
+
|
|
411
463
|
async def check_api_health(self) -> bool:
|
|
412
464
|
"""Проверяет доступность OpenAI API"""
|
|
413
465
|
try:
|
|
414
466
|
params = self._get_completion_params(max_tokens=10)
|
|
415
467
|
params["messages"] = [{"role": "user", "content": "Привет"}]
|
|
416
|
-
|
|
417
|
-
|
|
468
|
+
|
|
469
|
+
await self.client.chat.completions.create(**params)
|
|
418
470
|
return True
|
|
419
471
|
except Exception as e:
|
|
420
472
|
logger.error(f"OpenAI API недоступен: {e}")
|
|
421
473
|
return False
|
|
422
|
-
|
|
474
|
+
|
|
423
475
|
def estimate_tokens(self, text: str) -> int:
|
|
424
476
|
"""Более точная оценка количества токенов в тексте"""
|
|
425
477
|
# Для русского текста: примерно 2.5-3 символа на токен
|
|
426
478
|
return int(len(text) / 2.5)
|
|
427
|
-
|
|
479
|
+
|
|
428
480
|
async def get_available_models(self) -> List[str]:
|
|
429
481
|
"""Получает список доступных моделей"""
|
|
430
482
|
try:
|
|
431
483
|
models = await self.client.models.list()
|
|
432
|
-
return [model.id for model in models.data if
|
|
484
|
+
return [model.id for model in models.data if "gpt" in model.id.lower()]
|
|
433
485
|
except Exception as e:
|
|
434
486
|
logger.error(f"Ошибка при получении списка моделей: {e}")
|
|
435
487
|
return []
|
|
436
|
-
|
|
488
|
+
|
|
437
489
|
async def transcribe_audio(self, audio_file_path: str) -> str:
|
|
438
490
|
"""
|
|
439
491
|
Распознает голосовое сообщение через Whisper API
|
|
440
|
-
|
|
492
|
+
|
|
441
493
|
Args:
|
|
442
494
|
audio_file_path: Путь к аудио файлу
|
|
443
|
-
|
|
495
|
+
|
|
444
496
|
Returns:
|
|
445
497
|
Распознанный текст
|
|
446
498
|
"""
|
|
447
499
|
try:
|
|
448
500
|
logger.info(f"🎤 Отправка аудио на распознавание: {audio_file_path}")
|
|
449
|
-
|
|
450
|
-
with open(audio_file_path,
|
|
501
|
+
|
|
502
|
+
with open(audio_file_path, "rb") as audio_file:
|
|
451
503
|
transcript = await self.client.audio.transcriptions.create(
|
|
452
|
-
model="whisper-1",
|
|
453
|
-
file=audio_file,
|
|
454
|
-
language="ru" # Русский язык
|
|
504
|
+
model="whisper-1", file=audio_file, language="ru" # Русский язык
|
|
455
505
|
)
|
|
456
|
-
|
|
506
|
+
|
|
457
507
|
text = transcript.text
|
|
458
508
|
logger.info(f"✅ Распознано {len(text)} символов: '{text[:100]}...'")
|
|
459
509
|
return text
|
|
460
|
-
|
|
510
|
+
|
|
461
511
|
except Exception as e:
|
|
462
512
|
logger.error(f"❌ Ошибка распознавания аудио: {e}")
|
|
463
|
-
return ""
|
|
513
|
+
return ""
|