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