Files

844 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Internal MoonWell bridge between mod-playerbots-ai and Yandex AI Studio."""
from __future__ import annotations
import json
import hashlib
import logging
import os
import re
import sqlite3
import threading
import time
import urllib.error
import urllib.request
from collections import defaultdict, deque
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
LOG = logging.getLogger("playerbots-ai")
logging.basicConfig(
level=os.getenv("PLAYERBOTS_AI_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(message)s",
)
HOST = os.getenv("PLAYERBOTS_AI_HOST", "0.0.0.0")
PORT = int(os.getenv("PLAYERBOTS_AI_PORT", "8080"))
YANDEX_API_KEY = os.getenv("YANDEX_AI_API_KEY", "").strip()
YANDEX_FOLDER_ID = os.getenv("YANDEX_AI_FOLDER_ID", "").strip()
YANDEX_BASE_URL = os.getenv(
"YANDEX_AI_BASE_URL", "https://ai.api.cloud.yandex.net/v1"
).rstrip("/")
YANDEX_MODEL_URI = os.getenv("YANDEX_AI_MODEL_URI", "").strip()
MODEL_URI = YANDEX_MODEL_URI or (
f"gpt://{YANDEX_FOLDER_ID}/aliceai-llm-flash" if YANDEX_FOLDER_ID else ""
)
REQUEST_TIMEOUT = float(os.getenv("PLAYERBOTS_AI_REQUEST_TIMEOUT", "12"))
TEMPERATURE = float(os.getenv("PLAYERBOTS_AI_TEMPERATURE", "0.4"))
MAX_TOKENS = int(os.getenv("PLAYERBOTS_AI_MAX_TOKENS", "120"))
MAX_REPLY_CHARACTERS = int(os.getenv("PLAYERBOTS_AI_MAX_REPLY_CHARACTERS", "240"))
HISTORY_MESSAGES = max(0, int(os.getenv("PLAYERBOTS_AI_HISTORY_MESSAGES", "6")))
LOG_API_RESPONSES = os.getenv("PLAYERBOTS_AI_LOG_API_RESPONSES", "1").strip().lower() in {
"1",
"true",
"yes",
"on",
}
COMMANDS_ENABLED = os.getenv("PLAYERBOTS_AI_COMMANDS_ENABLED", "1").strip().lower() in {
"1",
"true",
"yes",
"on",
}
CLASSIFIER_CACHE_ENABLED = os.getenv(
"PLAYERBOTS_AI_CLASSIFIER_CACHE_ENABLED", "1"
).strip().lower() in {"1", "true", "yes", "on"}
CLASSIFIER_CACHE_DB = os.getenv(
"PLAYERBOTS_AI_CLASSIFIER_CACHE_DB", "/data/classifier-cache.sqlite3"
)
CLASSIFIER_CACHE_TTL_SECONDS = max(
60, int(os.getenv("PLAYERBOTS_AI_CLASSIFIER_CACHE_TTL_SECONDS", "604800"))
)
CLASSIFIER_CACHE_MAX_ENTRIES = max(
100, int(os.getenv("PLAYERBOTS_AI_CLASSIFIER_CACHE_MAX_ENTRIES", "10000"))
)
SAFE_EXACT_COMMANDS = {
"accept",
"accept *",
"add all loot",
"attack",
"attack rti target",
"autogear",
"buff",
"disperse disable",
"flee",
"focus heal ?",
"focus heal clear",
"focus heal none",
"focus heal unset",
"follow",
"give leader",
"glyphs",
"grind",
"help",
"home",
"inv",
"invite",
"items",
"leave",
"lfg",
"ll all",
"ll gray",
"ll normal",
"ll quest",
"ll skill",
"los",
"maintenance",
"open items",
"outfit ?",
"pet aggressive",
"pet attack",
"pet defensive",
"pet follow",
"pet passive",
"pet stance",
"pet stay",
"quests",
"quests all",
"quests completed",
"quests incompleted",
"ready",
"release",
"reset",
"reset botAI",
"revive",
"roll",
"rti",
"rtsc",
"rtsc cancel",
"rtsc go save",
"rtsc toggle",
"runaway",
"spells",
"ss reset",
"stats",
"stay",
"summon",
"t",
"talents",
"talents spec list",
"talk",
"tame",
"tame family",
"tank attack",
"trainer",
"trainer learn",
"who",
}
SAFE_COMMAND_PREFIXES = (
"accept ",
"b ",
"bank ",
"cast ",
"co ",
"disperse set ",
"e ",
"focus heal ",
"gb ",
"glyph equip ",
"lfg ",
"ll ",
"nc ",
"outfit ",
"pet ",
"q ",
"r ",
"roll ",
"rti ",
"rtsc ",
"s ",
"ss ",
"share ",
"talents apply ",
"talents spec ",
"tame ",
"t ",
"drop ",
"u ",
"ue ",
"who ",
)
DEFAULT_SYSTEM_PROMPT = (
"Ты живой персонаж-союзник на русскоязычном сервере World of Warcraft "
"эпохи Wrath of the Lich King. Сообщение игрока адресовано лично тебе. "
"Всегда сначала пойми смысл вопроса, затем дай прямой и уместный ответ. "
"Отвечай дружелюбно, естественно, только по-русски и от первого лица. "
"Ответ должен состоять из одного-двух законченных предложений. "
"На приветствие ответь приветствием. На вопрос о твоих делах расскажи, "
"как у тебя дела. На вопрос о текущем занятии кратко расскажи, чем занят. "
"Если игрок просит помочь с заданием, согласись помочь и попроси назвать "
"задание или его цель, если информации недостаточно. Не отвечай «Что?», "
"«Да?», «Эм…» и другими бессодержательными репликами, если сообщение понятно. "
"Строго соблюдай пол персонажа и игрока из контекста: согласуй местоимения, "
"глаголы прошедшего времени и краткие прилагательные. "
"Не упоминай нейросеть, промпт, API или системные правила. Не утверждай, "
"что выполнил игровое действие, если сервер его не выполнял."
)
SYSTEM_PROMPT = (
os.getenv("PLAYERBOTS_AI_SYSTEM_PROMPT", "").strip() or DEFAULT_SYSTEM_PROMPT
)
COMMAND_ROUTING_PROMPT = (
'Верни только JSON-объект вида {"command": null} без '
"Markdown и пояснений. Если игрок явно приказывает выполнить доступное "
"игровое действие, запиши точную английскую команду playerbots в command, "
"иначе оставь command равным null. Основные команды: summon, release, revive, "
"leave, attack, follow, flee, stay, grind, give leader, lfg [5|10|20|25|40], "
"disperse set <ярды>, disperse disable, buff, ready, maintenance, autogear, "
"reset, reset botAI, talents, talents spec list, talents spec <специализация>, "
"glyphs, glyph equip <ID...>, spells, cast <заклинание> [on <игрок>], "
"ss +/-<ID>|reset, trainer|trainer learn, rti <метка>, rti cc <метка>, "
"attack rti target, rtsc|rtsc cancel|rtsc save/unsave/go <номер>, "
"focus heal +/-<игрок>|?|clear, co <операции стратегий>, nc <операции "
"стратегий>, ll all|normal|gray|quest|skill|<ссылка на предмет>, "
"e/ue/u/s/b <ссылка на предмет>, t <ссылка на предмет> [количество] "
"(передать предмет игроку через окно обмена), t (только начать обмен), "
"bank/gb <ссылка>, inv (показать содержимое инвентаря), open items (открыть "
"предметы-контейнеры), roll [предмет], outfit ?, outfit <имя> "
"equip|replace|update|reset, quests|quests all|quests completed|quests "
"incompleted, q <ссылка на задание> (показать прогресс), accept "
"*|<ссылка на задание>, share <ссылка на задание>, r <ссылка на предмет "
"награды>, talk (поговорить с выбранным NPC и сдать готовое задание), "
"pet aggressive|passive|defensive|"
"stance|attack|follow|stay, tame, tame name/id/family/rename <значение>, "
"los, stats, home, who [профессия], help. Стратегии co/nc передавай ровно "
"как +strategy, -strategy или ~strategy; известные стратегии включают tank, "
"tank assist, dps, cc, assist, aoe, boost, threat, grind, heal, focus, "
"avoid aoe, save mana, healer dps, tank face, behind, pull, pull back, "
"mark rti, follow, food, pvp, loot, new rpg, bear, cat, caster, frost, fire, "
"bmight, bwisdom, bkings, bsanc, bdps, bspeed, bmana, imp, voidwalker, "
"succubus, felhunter, felguard, ss master, ss self, ss tank, ss healer, "
"moltencore, bwl, aq20, karazhan, gruulslair, magtheridon, ssc, voa, naxx, "
"wotlk-os, wotlk-eoe, ulduar, onyxia, icc. Сохраняй ссылки WoW, имена, "
"названия заклинаний и предметов из сообщения без изменений. Никогда не "
"создавай команды с точкой, playerbot/playerbots, account, debug, cdebug, "
"cheat, destroy, mail, sendmail, init или console. Команду drop <задание> "
"создавай только по явной просьбе отказаться, бросить или удалить конкретное "
"задание. Если это обычный "
"разговор, вопрос, приветствие или неоднозначное пожелание, command должен "
"быть null. Вопрос «можешь ли ты» сам по себе не является приказом. "
"Примеры точного преобразования: «иди за мной» → "
"follow; «стой здесь» → stay; «покажи инвентарь» → inv; «покажи "
"характеристики» → stats; «покажи заклинания» → spells; «включи режим "
"танка» → co +tank; «выключи АоЕ» → "
"co -aoe; «начни собирать добычу» → nc +loot; «перестань следовать» → "
"nc -follow; «сделай череп приоритетной целью» → rti skull; «назначь луну "
"целью контроля» → rti cc moon; «начни обмен» → t; «дай мне [предмет]» → "
"t [предмет]; «покажи все задания» → quests all; «покажи прогресс "
"[задания]» → q [задание]; «прими все задания» → accept *; «поделись "
"[заданием]» → share [задание]; «выбери [предмет] в награду» → r [предмет]; "
"«сдай готовое задание» → talk; «обслужи "
"экипировку и выучи доступные навыки» → maintenance."
)
CLASS_NAMES = {
1: "воин",
2: "паладин",
3: "охотник",
4: "разбойник",
5: "жрец",
6: "рыцарь смерти",
7: "шаман",
8: "маг",
9: "чернокнижник",
11: "друид",
}
RACE_NAMES = {
1: "человек",
2: "орк",
3: "дворф",
4: "ночной эльф",
5: "нежить",
6: "таурен",
7: "гном",
8: "тролль",
10: "эльф крови",
11: "дреней",
}
history: dict[str, deque[dict[str, str]]] = defaultdict(
lambda: deque(maxlen=HISTORY_MESSAGES)
)
history_lock = threading.Lock()
cache_stats_lock = threading.Lock()
classifier_cache_hits = 0
classifier_cache_misses = 0
classifier_cache_available = CLASSIFIER_CACHE_ENABLED
def configured() -> bool:
return bool(YANDEX_API_KEY and YANDEX_FOLDER_ID and MODEL_URI)
def initialize_classifier_cache() -> None:
global classifier_cache_available
if not CLASSIFIER_CACHE_ENABLED:
return
try:
directory = os.path.dirname(CLASSIFIER_CACHE_DB)
if directory:
os.makedirs(directory, exist_ok=True)
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
database.execute("PRAGMA journal_mode=WAL")
database.execute(
"""
CREATE TABLE IF NOT EXISTS classifier_cache (
cache_key TEXT PRIMARY KEY,
command TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
except (OSError, sqlite3.Error) as error:
classifier_cache_available = False
LOG.error("classifier cache unavailable, continuing without it: %s", error)
def classifier_cache_key(payload: dict[str, Any]) -> str:
normalized = normalized_routing_text(payload)
prompt_version = hashlib.sha256(COMMAND_ROUTING_PROMPT.encode("utf-8")).hexdigest()[:16]
return f"{prompt_version}:{normalized}"
def get_cached_classifier_command(payload: dict[str, Any]) -> tuple[bool, str]:
global classifier_cache_hits, classifier_cache_misses
if not classifier_cache_available:
return False, ""
cache_key = classifier_cache_key(payload)
cutoff = int(time.time()) - CLASSIFIER_CACHE_TTL_SECONDS
try:
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
row = database.execute(
"SELECT command, updated_at FROM classifier_cache WHERE cache_key = ?",
(cache_key,),
).fetchone()
if row and row[1] < cutoff:
database.execute(
"DELETE FROM classifier_cache WHERE cache_key = ?", (cache_key,)
)
row = None
except sqlite3.Error as error:
LOG.error("classifier cache read failed, using API: %s", error)
return False, ""
with cache_stats_lock:
if row:
classifier_cache_hits += 1
else:
classifier_cache_misses += 1
if not row:
return False, ""
LOG.info(
"classifier cache hit: key=%s result=%s",
hashlib.sha256(cache_key.encode("utf-8")).hexdigest()[:12],
row[0] or "chat",
)
return True, row[0]
def cache_classifier_command(payload: dict[str, Any], command: str) -> None:
if not classifier_cache_available:
return
now = int(time.time())
cutoff = now - CLASSIFIER_CACHE_TTL_SECONDS
try:
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
database.execute(
"""
INSERT INTO classifier_cache(cache_key, command, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
command = excluded.command,
updated_at = excluded.updated_at
""",
(classifier_cache_key(payload), command, now),
)
database.execute(
"DELETE FROM classifier_cache WHERE updated_at < ?", (cutoff,)
)
count = database.execute(
"SELECT COUNT(*) FROM classifier_cache"
).fetchone()[0]
excess = count - CLASSIFIER_CACHE_MAX_ENTRIES
if excess > 0:
database.execute(
"""
DELETE FROM classifier_cache WHERE cache_key IN (
SELECT cache_key FROM classifier_cache
ORDER BY updated_at ASC LIMIT ?
)
""",
(excess,),
)
except sqlite3.Error as error:
LOG.error("classifier cache write failed, continuing without cache: %s", error)
def classifier_cache_entry_count() -> int:
if not classifier_cache_available or not os.path.exists(CLASSIFIER_CACHE_DB):
return 0
try:
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
return database.execute(
"SELECT COUNT(*) FROM classifier_cache"
).fetchone()[0]
except sqlite3.Error:
return 0
def required_string(payload: dict[str, Any], key: str, max_length: int) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{key} must be a non-empty string")
return value.strip()[:max_length]
def integer(payload: dict[str, Any], key: str, default: int = 0) -> int:
value = payload.get(key, default)
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{key} must be an integer")
return value
def clean_reply(text: str) -> str:
compact = " ".join(text.replace("\r", " ").replace("\n", " ").split())
if len(compact) > MAX_REPLY_CHARACTERS:
compact = compact[:MAX_REPLY_CHARACTERS].rstrip() + ""
return compact
def validated_native_command(arguments: Any) -> str:
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError:
return ""
if not isinstance(arguments, dict):
return ""
command = arguments.get("command")
if not isinstance(command, str):
return ""
command = command.strip()
if (
not command
or len(command) > 300
or "\\" in command
or any(ord(character) < 0x20 for character in command)
):
return ""
if command in SAFE_EXACT_COMMANDS:
return command
if any(
command.startswith(prefix) and len(command) > len(prefix)
for prefix in SAFE_COMMAND_PREFIXES
):
return command
return ""
def normalized_command_text(text: str) -> str:
folded = text.lower().replace("ё", "е")
return " ".join(
"".join(character if character.isalnum() else " " for character in folded).split()
)
def normalized_routing_text(payload: dict[str, Any]) -> str:
message = required_string(payload, "message", 1000)
bot_name = required_string(payload, "bot_name", 64)
message_without_name = re.sub(
re.escape(bot_name), " ", message, flags=re.IGNORECASE
)
return normalized_command_text(message_without_name)
def is_obvious_social_chat(payload: dict[str, Any]) -> bool:
text = normalized_routing_text(payload)
patterns = (
r"^(?:привет|здравствуй|здравствуйте|добрый день|добрый вечер)\b",
r"\bкак (?:у тебя )?дела\b",
r"\bкак (?:твое|твоё) настроение\b",
r"\bчто ты (?:делаешь|делаешь тут|сейчас делаешь)\b",
r"\b(?:расскажи|поведай) (?:о|про) себя\b",
r"\bкто ты\b",
)
return any(re.search(pattern, text) for pattern in patterns)
def deterministic_command(message: str) -> str:
text = normalized_command_text(message)
item_match = re.search(
r"(?:\|c[0-9a-fA-F]{8})?\|Hitem:[^|]+\|h\[[^]]+\]\|h(?:\|r)?",
message,
)
quest_match = re.search(
r"(?:\|c[0-9a-fA-F]{8})?\|Hquest:[^|]+\|h\[[^]]+\]\|h(?:\|r)?",
message,
)
item_link = item_match.group(0) if item_match else ""
quest_link = quest_match.group(0) if quest_match else ""
if item_link and re.search(
r"\b(?:выбери|возьми)\b.*\b(?:награду|награде|награды)\b"
r"|\b(?:награду|награде|награды)\b.*\b(?:выбери|возьми)\b",
text,
):
return f"r {item_link}"
if item_link and re.search(
r"\b(?:дай|передай|отдай|вручи|обменяй)\b", text
):
return f"t {item_link}"
if quest_link:
if re.search(r"\b(?:прими|возьми)\b", text):
return f"accept {quest_link}"
if re.search(r"\b(?:поделись|расшарь|передай)\b", text):
return f"share {quest_link}"
if re.search(r"\b(?:откажись|брось|удали)\b", text):
return f"drop {quest_link}"
if re.search(
r"\b(?:покажи|расскажи|проверь)\b.*\b(?:задание|задания|квест|квеста|прогресс)\b",
text,
):
return f"q {quest_link}"
patterns = (
(r"\b(?:следуй|иди|пойдем|пошли)\s+за\s+мной\b", "follow"),
(
r"\b(?:стой|останься|оставайся|жди|подожди)\s+"
r"(?:тут|здесь|на\s+месте)\b",
"stay",
),
(r"\bне\s+двигайся\b", "stay"),
(r"\b(?:атакуй|бей|ударь)\s+(?:мою\s+)?цель\b", "attack"),
(r"\b(?:отступай|убегай|беги\s+ко\s+мне)\b", "flee"),
(r"\bпригласи\s+меня\s+(?:в\s+)?(?:группу|пати|рейд)\b", "invite"),
(r"\b(?:выйди|покинь)\s+(?:из\s+)?(?:группу|группы|пати|рейд|рейда)\b", "leave"),
(r"\b(?:собери|подбери)\s+(?:всю\s+)?(?:добычу|лут)\b", "add all loot"),
(r"\b(?:воскресни|оживи)\b", "revive"),
(r"\b(?:освободи|выпусти)\s+дух\b", "release"),
(r"\b(?:телепортируйся|призовись)\s+ко\s+мне\b", "summon"),
(r"\b(?:бафни|усиль)\s+(?:меня|группу|пати|нас)\b", "buff"),
(
r"\b(?:покажи|открой)\s+(?:мне\s+)?"
r"(?:свой\s+|свои\s+)?(?:инвентарь|сумки|вещи)\b",
"inv",
),
(
r"\b(?:начни|открой|предложи)\s+(?:мне\s+)?обмен\b"
r"|\bдавай\s+обменяемся\b"
r"|\bдай\s+мне\s+(?:свои\s+)?предметы\b",
"t",
),
(
r"\b(?:покажи|перечисли)\s+(?:мне\s+)?(?:все\s+)?"
r"(?:свои\s+)?(?:задания|квесты)\b"
r"|\bкакие\s+у\s+тебя\s+(?:задания|квесты)\b",
"quests all",
),
(
r"\b(?:покажи|перечисли)\s+(?:мне\s+)?(?:завершенные|выполненные)\s+"
r"(?:задания|квесты)\b",
"quests completed",
),
(
r"\b(?:покажи|перечисли)\s+(?:мне\s+)?"
r"(?:незавершенные|невыполненные)\s+(?:задания|квесты)\b",
"quests incompleted",
),
(r"\b(?:прими|возьми)\s+все\s+(?:задания|квесты)\b", "accept *"),
(
r"\b(?:сдай|заверши)\s+(?:готовое\s+|выполненное\s+)?"
r"(?:задание|квест)\b",
"talk",
),
)
for pattern, command in patterns:
if re.search(pattern, text):
return command
return ""
def gender_context(value: int, subject: str) -> str:
if value == 0:
return (
f"{subject}: мужской пол; используй соответствующие мужские "
"местоимения и формы слов."
)
if value == 1:
return (
f"{subject}: женский пол; используй соответствующие женские "
"местоимения и формы слов."
)
return (
f"{subject}: пол неизвестен; избегай местоимений и форм слов, "
"указывающих на пол."
)
def command_acknowledgement(payload: dict[str, Any]) -> str:
bot_gender = integer(payload, "bot_gender", 2)
if bot_gender == 0:
return "Понял команду."
if bot_gender == 1:
return "Поняла команду."
return "Команда понята."
def build_messages(payload: dict[str, Any]) -> tuple[str, list[dict[str, str]]]:
bot_guid = required_string(payload, "bot_guid", 32)
player_guid = required_string(payload, "player_guid", 32)
bot_name = required_string(payload, "bot_name", 64)
player_name = required_string(payload, "player_name", 64)
message = required_string(payload, "message", 1000)
bot_class = CLASS_NAMES.get(integer(payload, "bot_class"), "приключенец")
bot_race = RACE_NAMES.get(integer(payload, "bot_race"), "неизвестная раса")
bot_gender = integer(payload, "bot_gender", 2)
player_gender = integer(payload, "player_gender", 2)
bot_level = integer(payload, "bot_level")
zone_id = integer(payload, "zone_id")
conversation_key = f"{bot_guid}:{player_guid}"
context = (
f"Ты — {bot_name}, {bot_race}, {bot_class}, уровень {bot_level}. "
f"{gender_context(bot_gender, 'Персонаж')} "
f"Игрока зовут {player_name}. {gender_context(player_gender, 'Игрок')} "
f"Текущая игровая зона имеет ID {zone_id}."
)
messages: list[dict[str, str]] = [
{"role": "system", "content": SYSTEM_PROMPT},
]
messages.append({"role": "system", "content": context})
with history_lock:
messages.extend(list(history[conversation_key]))
messages.append({"role": "user", "content": message})
return conversation_key, messages
def request_yandex(
messages: list[dict[str, str]],
*,
purpose: str,
structured: bool,
temperature: float,
) -> str:
request_payload: dict[str, Any] = {
"model": MODEL_URI,
"messages": messages,
"temperature": temperature,
"max_tokens": MAX_TOKENS,
"stream": False,
}
if structured:
request_payload["response_format"] = {"type": "json_object"}
request_body = json.dumps(request_payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
f"{YANDEX_BASE_URL}/chat/completions",
data=request_body,
method="POST",
headers={
"Authorization": f"Api-Key {YANDEX_API_KEY}",
"OpenAI-Project": YANDEX_FOLDER_ID,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "MoonWell-playerbots-ai/1.0",
},
)
try:
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
response_payload = json.load(response)
except urllib.error.HTTPError as error:
error_body = error.read(2048).decode("utf-8", errors="replace")
raise RuntimeError(f"Yandex API HTTP {error.code}: {error_body}") from error
except urllib.error.URLError as error:
raise RuntimeError(f"Yandex API connection failed: {error.reason}") from error
if LOG_API_RESPONSES:
LOG.info(
"Yandex API %s response: %s",
purpose,
json.dumps(response_payload, ensure_ascii=False, separators=(",", ":")),
)
try:
assistant_message = response_payload["choices"][0]["message"]
except (KeyError, IndexError, TypeError) as error:
raise RuntimeError("Yandex API response has no assistant message") from error
content = assistant_message.get("content")
if not isinstance(content, str):
raise RuntimeError("Yandex API returned non-text assistant content")
return content
def query_yandex(payload: dict[str, Any]) -> tuple[str, str]:
conversation_key, chat_messages = build_messages(payload)
command = ""
if COMMANDS_ENABLED:
command = deterministic_command(payload["message"])
if command:
return command_acknowledgement(payload), command
skip_classifier = is_obvious_social_chat(payload)
cache_hit, command = (False, "")
if not skip_classifier:
cache_hit, command = get_cached_classifier_command(payload)
if not skip_classifier and not cache_hit:
routing_content = request_yandex(
[
{"role": "system", "content": COMMAND_ROUTING_PROMPT},
{"role": "user", "content": payload["message"]},
],
purpose="classifier",
structured=True,
temperature=0.1,
)
try:
routed_response = json.loads(routing_content)
except json.JSONDecodeError as error:
raise RuntimeError("Yandex API returned invalid command JSON") from error
if not isinstance(routed_response, dict):
raise RuntimeError("Yandex API returned non-object command JSON")
command = validated_native_command(routed_response)
if routed_response.get("command") is not None and not command:
return "Эту команду я не могу выполнить.", ""
cache_classifier_command(payload, command)
if command:
return command_acknowledgement(payload), command
reply = clean_reply(
request_yandex(
chat_messages,
purpose="chat",
structured=False,
temperature=TEMPERATURE,
)
)
if not reply:
raise RuntimeError("Yandex API returned an empty chat reply")
with history_lock:
conversation = history[conversation_key]
conversation.append({"role": "user", "content": payload["message"]})
conversation.append({"role": "assistant", "content": reply})
return reply, ""
class RequestHandler(BaseHTTPRequestHandler):
server_version = "MoonWellPlayerbotsAI/1.0"
def log_message(self, format_string: str, *args: Any) -> None:
LOG.debug("%s - %s", self.client_address[0], format_string % args)
def send_text(self, status: HTTPStatus, text: str) -> None:
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if self.path != "/health":
self.send_text(HTTPStatus.NOT_FOUND, "not found")
return
self.send_json(
HTTPStatus.OK,
{
"status": "ok",
"configured": configured(),
"model": MODEL_URI or None,
"commands_enabled": COMMANDS_ENABLED,
"log_api_responses": LOG_API_RESPONSES,
"classifier_cache_enabled": CLASSIFIER_CACHE_ENABLED,
"classifier_cache_available": classifier_cache_available,
"classifier_cache_entries": classifier_cache_entry_count(),
"classifier_cache_hits": classifier_cache_hits,
"classifier_cache_misses": classifier_cache_misses,
},
)
def do_POST(self) -> None:
if self.path != "/v1/chat":
self.send_text(HTTPStatus.NOT_FOUND, "not found")
return
if not configured():
self.send_text(
HTTPStatus.SERVICE_UNAVAILABLE,
"Yandex AI credentials are not configured",
)
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_text(HTTPStatus.BAD_REQUEST, "invalid Content-Length")
return
if content_length <= 0 or content_length > 32768:
self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "invalid request size")
return
try:
payload = json.loads(self.rfile.read(content_length))
if not isinstance(payload, dict):
raise ValueError("request body must be an object")
reply, command = query_yandex(payload)
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as error:
self.send_text(HTTPStatus.BAD_REQUEST, str(error))
return
except Exception as error:
LOG.error("LLM request failed: %s", error)
self.send_text(HTTPStatus.BAD_GATEWAY, "LLM provider request failed")
return
self.send_text(HTTPStatus.OK, f"PB-COMMAND:{command}\n{reply}")
def main() -> None:
initialize_classifier_cache()
LOG.info(
"starting bridge on %s:%d, configured=%s, model=%s",
HOST,
PORT,
configured(),
MODEL_URI or "<not configured>",
)
ThreadingHTTPServer((HOST, PORT), RequestHandler).serve_forever()
if __name__ == "__main__":
main()