ai_bot / bot.py
gloomy_pooplar
Improve search: pymorphy3 stemming, top_k=4, RRF 0.3/0.7, restructured KB
5de4047
Raw
History Blame Contribute Delete
9.15 kB
"""ORTOS AI Consultant — core message processing.
Groq (primary) -> local Llama 3.2 3B (fallback) -> 'not sure'.
Webhook handler for Bitrix24 Open Lines.
"""
import os, re, time, logging
from collections import deque
from openai import OpenAI
import httpx
from dotenv import load_dotenv
from log_store import add as log_add
from knowledge import search_debug
load_dotenv()
logger = logging.getLogger(__name__)
GROQ_API_KEY = os.getenv('GROQ_API_KEY')
LLAMA_MODEL_PATH = os.getenv('LLAMA_MODEL_PATH')
from knowledge import reload_knowledge
kb_items, kb_tfidf = reload_knowledge(['knowledge_base.json', 'knowledge_base_insoles.json'])
_llm = None
try:
from llama_cpp import Llama
if LLAMA_MODEL_PATH and os.path.exists(LLAMA_MODEL_PATH):
logger.info("Pre-loading local LLM...")
_llm = Llama(model_path=LLAMA_MODEL_PATH, n_ctx=2048, n_threads=2, verbose=False)
logger.info("Local LLM loaded")
elif LLAMA_MODEL_PATH:
logger.warning(f"LLAMA_MODEL_PATH set but file not found: {LLAMA_MODEL_PATH}")
except Exception as e:
logger.warning(f"Local LLM unavailable: {e}")
OPERATOR_TRIGGERS = [
# Явный запрос человека
re.compile(r'(оператор|человек|менеджер|специалист)\s', re.I),
re.compile(r'(свяжите|соедините|позовите|пригласите)', re.I),
re.compile(r'(живой|живого|живому)\s', re.I),
# Отмена/отказ
re.compile(r'отмен(ит|и|ю|я|иться|ять)', re.I),
re.compile(r'(удал|отписк|откаж|отзов|отпиш)(ит|и|ю|я|ись)', re.I),
re.compile(r'расторж(ение|ения|нуть)', re.I),
# Возврат/обмен/гарантия
re.compile(r'(возврат|обмен|замен|гаранти[йя])', re.I),
re.compile(r'(брак|дефект|не\s*подо[шй]л|не\s*работает)', re.I),
# Жалобы/проблемы
re.compile(r'(жалоб[ауы]|претензи[яю]|недовол(ен|ьна|ьны)|разочар)', re.I),
re.compile(r'(плох[аяоеи]{1,2})\s+', re.I),
# Продление/изменение
re.compile(r'(продл|продлит|продление)', re.I),
re.compile(r'(дополнител[ь]н|изменени[ея])', re.I),
# Медицина/здоровье
re.compile(r'(болит|боль|врач|диагноз|противопоказан[ия])', re.I),
re.compile(r'(травм[ауы]|перелом|шин[ау]|гипс[еа])', re.I),
# Запись в салон
re.compile(r'(записать(ся|сь)|запишите|запись\s+на)', re.I),
re.compile(r'(прийт[иё]|придт[иё]|приехат[ьы])', re.I),
# Проблемы с заказом
re.compile(r'(где\s+заказ|статус\s+заказ[ао]|отследить)', re.I),
re.compile(r'(не\s+приш[её]л|не\s+доставил[и]?|курьер)', re.I),
]
GREETINGS = {'привет', 'здравствуйте', 'здравствуй', 'добрый день', 'доброе утро',
'добрый вечер', 'хай', 'hi', 'hello', 'приветствую', 'салют'}
SYSTEM_PROMPT = (
"Ты — консультант по индивидуальным ортопедическим стелькам салона ORTOS. "
"Ты отвечаешь ТОЛЬКО на вопросы об индивидуальных стельках: их изготовлении, "
"материалах, показаниях, сроках, уходе, ценах. "
"Используй ТОЛЬКО переданную информацию. "
"Не выдумывай цены, характеристики, сроки. "
"Если вопрос не про индивидуальные стельки — просто напиши: "
"'Переведу вас на оператора.' "
"Если переданная информация не отвечает на вопрос клиента — тоже напиши: "
"'Переведу вас на оператора.' "
)
dialog_history: dict[str, deque] = {}
def need_operator(text: str) -> bool:
text_lower = text.lower()
for pattern in OPERATOR_TRIGGERS:
if pattern.search(text_lower):
return True
return False
def _build_user_message(question: str, context_items, history: deque | None = None):
if not context_items:
context = "(нет информации)"
else:
context = "\n\n".join(f"[{item.title}]\n{item.content}" for item in context_items)
hist_text = ""
if history:
lines = []
for msg in history:
role = "Клиент" if msg["role"] == "user" else "ORTOS"
lines.append(f"{role}: {msg['content']}")
hist_text = "История диалога:\n" + "\n".join(lines) + "\n\n"
return (
f"{hist_text}"
f"Вопрос клиента: {question}\n\n"
f"Доступная информация из базы знаний ORTOS:\n{context}\n\n"
"Дай точный ответ на вопрос клиента, используя ТОЛЬКО информацию выше. "
"Если в информации нет нужных данных — скажи что не знаешь."
)
def get_grok_response(question: str, context_items, history: deque | None = None) -> str | None:
if not GROQ_API_KEY:
return None
try:
client = OpenAI(
api_key=GROQ_API_KEY,
base_url="https://api.groq.com/openai/v1",
http_client=httpx.Client(proxy=None),
)
response = client.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _build_user_message(question, context_items, history)},
],
temperature=0.1,
max_tokens=500,
)
return response.choices[0].message.content
except Exception as e:
logger.warning(f"Groq error: {e}")
return None
def get_local_response(question: str, context_items, history: deque | None = None) -> str | None:
if _llm is None:
return None
try:
response = _llm.create_chat_completion(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _build_user_message(question, context_items, history)},
],
temperature=0.1,
max_tokens=500,
)
return response["choices"][0]["message"]["content"]
except Exception as e:
logger.warning(f"Local LLM error: {e}")
return None
def process_message(text: str, dialog_id: str):
"""Process message, return reply text and send to Bitrix."""
if dialog_id not in dialog_history:
dialog_history[dialog_id] = deque(maxlen=10)
text_lower = text.lower()
if text_lower in GREETINGS or any(text_lower.startswith(g) for g in GREETINGS if " " in g):
reply = "Здравствуйте! Я — консультант салона ORTOS. Спросите что-нибудь о наших стельках, ценах, доставке, записи на консультацию!"
log_add(question=text, response=reply, mode="greeting", search_method="—", timing_ms=0)
dialog_history[dialog_id].append({"role": "user", "content": text})
dialog_history[dialog_id].append({"role": "assistant", "content": reply})
return reply
if need_operator(text):
reply = "Переход на оператора..."
log_add(question=text, response=reply, mode="operator", search_method="—", timing_ms=0)
dialog_history.pop(dialog_id, None)
return reply
t0 = time.time()
debug = search_debug(text, top_k=4)
results = debug["items"]
t1 = time.time()
response = get_grok_response(text, results, dialog_history.get(dialog_id))
mode = "groq"
llm_model = "llama-3.3-70b-versatile (Groq)"
if response is None:
response = get_local_response(text, results, dialog_history.get(dialog_id))
mode = "local"
llm_model = "Llama 3.2 3B (local)"
if response is None:
response = "Извините, не удалось получить ответ. Попробуйте позже или позвоните +375 (29) 145-03-03."
mode = "fallback"
llm_model = ""
t2 = time.time()
dialog_history[dialog_id].append({"role": "user", "content": text})
dialog_history[dialog_id].append({"role": "assistant", "content": response})
log_add(
question=text,
response=response,
mode=mode,
search_method=debug["method"],
search_details=debug["details"],
llm_model=llm_model,
timing_ms=round((t1 - t0) * 1000 + (t2 - t1) * 1000),
)
return response