ai_bot / server.py
gloomy_pooplar
fix: general greeting, LLM detects off-topic → operator transfer
f010f62
Raw
History Blame Contribute Delete
11.1 kB
"""Entry point for ORTOS AI Consultant.
Webhook handler for Bitrix24 Open Lines (outgoing webhooks).
"""
import os, json, logging, html, urllib.parse, re
from http.server import HTTPServer, BaseHTTPRequestHandler
from dotenv import load_dotenv
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
for _lib in ("httpx", "httpcore"):
logging.getLogger(_lib).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
load_dotenv()
PORT = int(os.getenv('PORT', '7860'))
BITRIX_WEBHOOK_URL = os.getenv('BITRIX_WEBHOOK_URL')
BOT_ID = int(os.getenv('BOT_ID', '0'))
from bot import process_message
_auto_bot_id = 0
_webhook_auth = {}
def _parse_form(body: bytes) -> dict:
parsed = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
out = {}
for key, vals in parsed.items():
val = vals[0] if len(vals) == 1 else vals
brackets = re.findall(r"\[([^\]]+)\]", key)
if brackets:
section = key.split("[")[0]
d = out.setdefault(section, {})
for b in brackets[:-1]:
d = d.setdefault(b, {})
d[brackets[-1]] = val
else:
out[key] = val
return out
def _ensure_bot_id(data: dict):
global _auto_bot_id
if _auto_bot_id:
return
bots = data.get("data", {}).get("BOT", {})
for bid in bots:
try:
_auto_bot_id = int(bid)
logger.info(f"Auto-detected BOT_ID={_auto_bot_id}")
return
except (ValueError, TypeError):
pass
def _send_reply(dialog_id: str, text: str):
bid = _auto_bot_id or BOT_ID
if not bid:
logger.error(f"Cannot send: bot_id={bid}")
return
import httpx
client_id = _webhook_auth.get("application_token", "") if _webhook_auth else ""
# Try 1: webhook URL + CLIENT_ID (the error said "Client ID not specified")
if BITRIX_WEBHOOK_URL and client_id:
url = f"{BITRIX_WEBHOOK_URL}imbot.message.add.json"
try:
resp = httpx.post(url, params={"CLIENT_ID": client_id}, json={
"BOT_ID": bid,
"DIALOG_ID": dialog_id,
"MESSAGE": text,
}, timeout=30)
logger.info(f"Reply (webhook+CLIENT_ID) {dialog_id}: {resp.status_code} {resp.text[:300]}")
if resp.status_code == 200:
return
except Exception as e:
logger.warning(f"Webhook+CLIENT_ID failed: {e}")
# Try 2: webhook URL without CLIENT_ID (might work for im methods)
if BITRIX_WEBHOOK_URL:
url = f"{BITRIX_WEBHOOK_URL}imbot.message.add.json"
try:
resp = httpx.post(url, json={
"BOT_ID": bid,
"DIALOG_ID": dialog_id,
"MESSAGE": text,
}, timeout=30)
logger.info(f"Reply (webhook no CLIENT_ID) {dialog_id}: {resp.status_code} {resp.text[:300]}")
if resp.status_code == 200:
return
except Exception as e:
logger.warning(f"Webhook URL attempt failed: {e}")
# Try 3: im.message.add as user 1
if BITRIX_WEBHOOK_URL:
url = f"{BITRIX_WEBHOOK_URL}im.message.add.json"
try:
resp = httpx.post(url, json={
"DIALOG_ID": dialog_id,
"MESSAGE": text,
}, timeout=30)
logger.info(f"Reply (im.message) {dialog_id}: {resp.status_code} {resp.text[:300]}")
except Exception as e:
logger.error(f"All send methods failed: {e}")
def transfer_to_operator(dialog_id: str):
chat_id = None
if dialog_id.startswith("chat"):
try:
chat_id = int(dialog_id.replace("chat", ""))
except ValueError:
pass
if not chat_id:
logger.error(f"Cannot transfer: no chat_id from {dialog_id}")
return
import httpx
client_id = _webhook_auth.get("application_token", "") if _webhook_auth else ""
# Send notification to user
_send_reply(dialog_id, "Оператор сейчас подключится. Пожалуйста, ожидайте.")
# Transfer chat to contact center
if BITRIX_WEBHOOK_URL:
url = f"{BITRIX_WEBHOOK_URL}imopenlines.bot.session.operator"
try:
resp = httpx.post(url, json={"CHAT_ID": chat_id}, timeout=30)
logger.info(f"Transfer to operator: {resp.status_code} {resp.text[:300]}")
except Exception as e:
logger.error(f"Transfer error: {e}")
class WebhookHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path in ("/", "/health"):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
return
if self.path == "/logs":
from log_store import get_log
entries = get_log()
page = """<!DOCTYPE html><html lang="ru"><head>
<meta charset="utf-8"><title>ORTOS Bot Logs</title>
<style>
body{font-family:sans-serif;margin:20px;background:#111;color:#eee}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #333;vertical-align:top}
th{background:#222;color:#0f0;position:sticky;top:0}
tr:hover{background:#1a1a1a}
.mode{font-weight:bold;padding:2px 6px;border-radius:3px;font-size:11px;white-space:nowrap}
.groq{background:#1a3a1a;color:#4f4}
.local{background:#3a1a1a;color:#f88}
.fallback{background:#3a3a1a;color:#ff4}
.greeting{background:#1a1a3a;color:#44f}
.operator{background:#3a1a3a;color:#f4f}
.q{color:#ffa;max-width:250px;word-break:break-word}
.r{color:#afa;max-width:350px;word-break:break-word}
.detail{color:#888;font-size:11px;margin-top:4px;border-top:1px solid #333;padding-top:4px}
.lbl{color:#666}
.val{color:#eee}
.src{color:#8af}
.err{color:#f44}
summary{cursor:pointer;color:#8af;font-size:12px}
</style></head><body>
<h2>ORTOS Consultant &mdash; last 50 interactions</h2>
<table><thead><tr><th>Time</th><th>Mode</th><th>Q</th><th>Response</th><th>RAG</th></tr></thead>"""
for e in entries:
css = e["mode"]
md = e["search_method"]
llm = html.escape(e["llm_model"])
ms = e["timing_ms"]
rag_html = f"<span class='src'>{html.escape(md)}</span>"
rag_html += f"<br><span class='lbl'>LLM:</span> <span class='val'>{llm}</span>"
rag_html += f"<br><span class='lbl'>⏱</span> <span class='val'>{ms}ms</span>"
if e.get("search_details"):
rag_html += "<details><summary>search results</summary>"
for d in e["search_details"]:
rag_html += f"<div class='detail'>"
rag_html += f"<b class='src'>{html.escape(d.get('title',''))}</b>"
br = d.get("bm25_rank")
er = d.get("embed_rank")
rs = d.get("rrf_score")
if br is not None:
rag_html += f"<br><span class='lbl'>BM25 rank:</span> <span class='val'>{br}</span>"
if er is not None:
rag_html += f"<br><span class='lbl'>bge-m3 rank:</span> <span class='val'>{er}</span>"
if rs is not None:
rag_html += f"<br><span class='lbl'>RRF score:</span> <span class='val'>{rs}</span>"
rag_html += "</div>"
rag_html += "</details>"
page += f"<tr><td>{e['time']}</td><td><span class='mode {css}'>{e['mode']}</span></td>"
page += f"<td class='q'>{html.escape(e['question'])}</td>"
page += f"<td class='r'>{html.escape(e['response'])}</td>"
page += f"<td>{rag_html}</td></tr>"
page += "</table></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(page.encode())
return
self.send_response(404)
self.end_headers()
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
path_only = urllib.parse.urlparse(self.path).path
logger.info(f"POST {path_only} event={body.decode('utf-8', errors='replace')[:200]}")
if path_only != "/bitrix/openlines_webhook":
self._json_response(200, {"status": "unknown_path"})
return
data = _parse_form(body)
_ensure_bot_id(data)
global _webhook_auth
_webhook_auth = data.get("auth", {})
event = data.get("event", "")
params = data.get("data", {}).get("PARAMS", {})
logger.info(f"Parsed event={event} dialog={params.get('DIALOG_ID','')} msg='{str(params.get('MESSAGE',''))[:60]}'")
if event == "ONIMBOTJOINCHAT":
dialog_id = params.get("DIALOG_ID", "")
if dialog_id:
welcome = "Здравствуйте! Я — консультант салона ORTOS. Задайте мне вопрос о стельках, ценах, доставке или записи."
_send_reply(dialog_id, welcome)
self._json_response(200, {})
elif event == "ONIMBOTMESSAGEADD":
dialog_id = params.get("DIALOG_ID", "")
text = params.get("MESSAGE", params.get("TEXT", ""))
from_user = params.get("FROM_USER_ID", "")
is_system = params.get("SYSTEM", "N") == "Y"
if text and dialog_id and not is_system and str(from_user) != "0":
logger.info(f"Processing message from user {from_user}: {text[:80]}")
reply = process_message(text, dialog_id)
if reply.startswith("Переход на оператора") or "переведу вас на оператора" in reply.lower():
transfer_to_operator(dialog_id)
else:
_send_reply(dialog_id, reply)
elif not is_system and str(from_user) == "0":
logger.info("Skipping bot's own message")
self._json_response(200, {})
else:
self._json_response(200, {})
def _json_response(self, status, data):
body_b = json.dumps(data, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body_b)))
self.end_headers()
self.wfile.write(body_b)
def log_message(self, *args):
pass
def main():
server = HTTPServer(('0.0.0.0', PORT), WebhookHandler)
logger.info(f"ORTOS Consultant running on port {PORT}")
logger.info(f"Webhook URL: POST /bitrix/openlines_webhook")
server.serve_forever()
if __name__ == '__main__':
main()