"""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