v1.8.3: добавлены docstrings и комментарии по AGENTS.md ко всем ключевым файлам

This commit is contained in:
2026-06-02 18:53:21 +03:00
parent 4c3026a80f
commit a7a39bdd5d
19 changed files with 212 additions and 0 deletions
+9
View File
@@ -1,3 +1,12 @@
"""
Конфигурация max_bot (Pydantic Settings).
Читает из .env с префиксом BOT_:
- database_url: PostgreSQL URL
- max_token: токен MAX Platform
- webhook_url/webhook_secret: настройки webhook
- config_php_path: путь к config.php сайта
"""
from typing import List, Optional
from pydantic_settings import BaseSettings
+6
View File
@@ -1,3 +1,9 @@
"""
Чтение конфигурации из config.php (AegisOne сайт).
Парсит PHP define() константы и возвращает их как dict.
Fallback на hardcoded значения если файл не найден.
"""
from __future__ import annotations
import re
import os
+6
View File
@@ -1,3 +1,9 @@
"""
Асинхронное подключение к PostgreSQL через SQLAlchemy 2.0.
engine: create_async_engine с pool_size=10
async_session: async_sessionmaker для создания сессий
"""
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.config import settings
+5
View File
@@ -1,3 +1,8 @@
"""
Отправка email через sendmail (с fallback на логирование в файл).
Используется для уведомлений о заявках.
"""
import subprocess
import os
import logging
+20
View File
@@ -1,3 +1,11 @@
"""
Обработчик согласия на обработку персональных данных.
Управляет жизненным циклом согласия:
- запрос согласия → да/нет
- отзыв согласия (consent_revoked_date)
- повторное согласие после отказа
"""
import datetime
import re
import logging
@@ -12,6 +20,12 @@ logger = logging.getLogger(__name__)
def _matches_any(text: str, words: list) -> bool:
"""
Проверяет, содержит ли текст хотя бы одно из указанных слов.
Использует split для точного совпадения коротких слов (≤3 символов)
и подстроку для длинных (>3 символов).
"""
text_words = set(text.split())
for w in words:
if w in text_words:
@@ -22,6 +36,7 @@ def _matches_any(text: str, words: list) -> bool:
async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
"""Маршрутизация callback-кнопок согласия (да/нет)."""
if callback_id == "consent_yes":
await handle_consent_yes(user_id, conv_id)
elif callback_id == "consent_no":
@@ -29,6 +44,7 @@ async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str)
async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
"""Обработка текстового ответа на запрос согласия (да/нет/другое)."""
text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"]
@@ -48,6 +64,7 @@ async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None
async def handle_consent_yes(user_id: int, conv_id: int) -> None:
"""Обработка согласия: устанавливает consent_given=True, переводит в awaiting_contact."""
async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none()
@@ -78,6 +95,7 @@ async def handle_consent_yes(user_id: int, conv_id: int) -> None:
async def handle_consent_no(user_id: int, conv_id: int) -> None:
"""Обработка отказа: устанавливает consent_refused, показывает контакты."""
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
@@ -106,6 +124,7 @@ async def handle_consent_no(user_id: int, conv_id: int) -> None:
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
"""Повторная обработка после отказа: позволяет дать согласие или показывает контакты."""
text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"]
@@ -145,6 +164,7 @@ async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
async def handle_revoke_consent(user_id: int, conv_id: int) -> None:
"""Отзыв согласия: устанавливает consent_given=False, consent_revoked_date=now."""
async with async_session() as db:
user = await db.get(BotUser, user_id)
if user and user.consent_given:
+9
View File
@@ -1,3 +1,10 @@
"""
Обработчик контактов пользователя.
Принимает контакты (телефон, email) через:
- sharing контакта из мессенджера (VCF)
- ручной ввод текстом
"""
import datetime
import re
import logging
@@ -13,6 +20,7 @@ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
"""Обработка контакта из мессенджера: парсинг телефона/email, переход к заявке."""
async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none()
@@ -37,6 +45,7 @@ async def handle_contact_received(user_id: int, conv_id: int, contact_text: str)
async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None:
"""Обработка ручного ввода контакта: валидация телефона/email, отказ → consent_refused."""
text = text.strip()
text_lower = text.lower()
+35
View File
@@ -1,3 +1,13 @@
"""
Обработчик приветствий и основных сообщений бота.
Управляет жизненным циклом диалога:
- приветствие → создание пользователя и диалога
- маршрутизация по состояниям (state machine)
- анализ намерений через YandexGPT
- быстрые паттерны (заявка/приветствие) до NLP
- контекст из последних 10 минут
"""
import logging
import datetime
from typing import Optional
@@ -7,6 +17,7 @@ from app.database import async_session
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
from app.max_api import max_api
@@ -26,6 +37,7 @@ async def handle_greeting(
last_name: str = "",
username: str = "",
) -> None:
"""Обработка приветствия: создаёт/обновляет пользователя, создаёт диалог, отправляет приветствие."""
assistant_name = await settings_cache.get("assistant_name", "София")
greeting_text = (
@@ -94,6 +106,14 @@ async def handle_greeting(
async def handle_message(user_id: int, text: str) -> None:
"""
Основной обработчик сообщений. Маршрутизирует по состояниям:
- greeting/analyzing/awaiting_input → analyze_and_respond
- awaiting_consent/consent_refused → обработка согласия
- awaiting_contact → ручной ввод контакта
- awaiting_inquiry → обработка заявки
- completed/escalated → повторный анализ (escape hatch)
"""
async with async_session() as db:
result = await db.execute(
select(BotConversation)
@@ -199,6 +219,7 @@ async def handle_message(user_id: int, text: str) -> None:
async def _get_user_history_context(user_id: int) -> str:
"""Формирует контекст из последних сообщений (10 мин) и открытых заявок пользователя."""
from app.models import BotTicket
async with async_session() as db:
ten_min_ago = moscow_now() - datetime.timedelta(minutes=10)
@@ -237,12 +258,20 @@ async def _get_user_history_context(user_id: int) -> str:
async def _select_ai():
"""Выбор AI-провайдера (только YandexGPT)."""
if await yandex_gpt.is_available():
return yandex_gpt
return None
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
"""
Анализ намерений и генерация ответа.
Быстрые паттерны (заявка/приветствие) проверяются ПЕРЕД NLP.
Затем: analyze_intent → ticket/question/unknown → обработка.
clarify_intent вызывается не более 1 раза за диалог.
"""
try:
logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}")
@@ -359,6 +388,7 @@ async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
async def _limited_mode(user_id: int, conv_id: int) -> None:
"""Режим без AI: показывает контакты компании."""
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
@@ -372,6 +402,7 @@ async def _limited_mode(user_id: int, conv_id: int) -> None:
async def _send_contacts(user_id: int, conv_id: int = None) -> None:
"""Отправка контактных данных компании (телефон, email)."""
phones = config_reader.get_phones()
email = config_reader.get_site_mail()
@@ -387,6 +418,7 @@ async def _send_contacts(user_id: int, conv_id: int = None) -> None:
async def _get_knowledge_base_context(query: str) -> str:
"""Формирует контекст из базы знаний по ключевым словам (ранжирование по score)."""
async with async_session() as db:
result = await db.execute(
select(BotKnowledgeBase)
@@ -433,6 +465,7 @@ async def _get_knowledge_base_context(query: str) -> str:
def _texts_match(text_a: str, text_b: str) -> bool:
"""Проверка схожести двух текстов по пересечению слов (≥50%)."""
stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши",
"где", "когда", "сколько", "что", "это", "или", "для", "при",
"подскажите", "расскажите", "объясните", "пожалуйста"}
@@ -445,6 +478,7 @@ def _texts_match(text_a: str, text_b: str) -> bool:
async def _auto_generate_and_add_kb(db, question_record):
"""Автогенерация карточки KB через YandexGPT при 3+ обращениях."""
card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
if not card_data:
return
@@ -466,6 +500,7 @@ async def _auto_generate_and_add_kb(db, question_record):
async def _save_unknown_question(user_id: int, question_text: str) -> None:
"""Сохранение неизвестного вопроса с дедупликацией и автогенерацией при 3+ обращениях."""
normalized_input = question_text.lower().strip()
async with async_session() as db:
+7
View File
@@ -1,3 +1,8 @@
"""
Обработчик callback-кнопок и шаринга контактов.
Маршрутизирует callback-и по типу (consent_* и др.).
"""
import datetime
import logging
from sqlalchemy import select
@@ -10,6 +15,7 @@ logger = logging.getLogger(__name__)
async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None:
"""Маршрутизация callback-кнопок (consent_* → consent handler)."""
async with async_session() as db:
result = await db.execute(
select(BotConversation)
@@ -40,6 +46,7 @@ async def handle_contact_share(
vcf_data: dict = None,
contact_hash: str = "",
) -> None:
"""Обработка шаринга контакта из мессенджера: парсинг VCF, сохранение в BotUser."""
if vcf_data is None:
vcf_data = {}
async with async_session() as db:
+8
View File
@@ -1,3 +1,9 @@
"""
Обработчик заявок (inquiry).
Создаёт BotTicket с контактными данными пользователя,
отправляет уведомление на email, подтверждает пользователю.
"""
import datetime
import logging
from sqlalchemy import select
@@ -12,10 +18,12 @@ logger = logging.getLogger(__name__)
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
"""Обработка заявки: создаёт.ticket, отправляет email, подтверждает пользователю."""
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
+9
View File
@@ -1,3 +1,12 @@
"""
Клавиатуры для MAX Platform бота.
Типы кнопок:
- callback: callback-кнопка (consent_yes/consent_no)
- link: ссылка (политика конфиденциальности)
- request_contact: запрос контакта из мессенджера
- message: текстовая кнопка
"""
from typing import List, Dict, Any
POLITICA_URL = "https://aegisone.ru/politica.php"
+19
View File
@@ -1,3 +1,11 @@
"""
Главный модуль max_bot FastAPI-приложения.
Эндпоинты:
- /webhook: входящие webhook-и от MAX Platform
- /health: healthcheck
- /api/bot/*: CRUD для настроек, KB, заявок, диалогов, пользователей
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
@@ -54,6 +62,7 @@ app.add_middleware(
async def _apply_migrations():
"""Автоматические миграции: добавление колонок/таблиц при старте."""
async with engine.begin() as conn:
try:
await conn.execute(text("""
@@ -101,6 +110,7 @@ async def _apply_migrations():
async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str):
"""Безопасное добавление колонки (idempotent)."""
from sqlalchemy import text as sa_text
_validate_sql_name(table)
_validate_sql_name(column)
@@ -121,6 +131,7 @@ async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str
async def _create_table_if_not_exists(conn, table: str, create_sql: str):
"""Безопасное создание таблицы (idempotent)."""
from sqlalchemy import text as sa_text
try:
await conn.execute(sa_text(f"""
@@ -138,12 +149,14 @@ async def _create_table_if_not_exists(conn, table: str, create_sql: str):
def _validate_sql_name(name: str) -> None:
"""Валидация SQL-идентификатора (только буквы, цифры, подчёркивания)."""
import re
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name):
raise ValueError(f"Invalid SQL identifier: {name}")
def _validate_sql_type(col_type: str) -> None:
"""Валидация SQL-типа колонки (разрешённые типы)."""
import re
allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
if not re.match(allowed, col_type.strip()):
@@ -151,6 +164,7 @@ def _validate_sql_type(col_type: str) -> None:
async def _log_processing(user_id: int, step: str, status: str = "ok", message: str = None, detail: str = None, conversation_id: int = None):
"""Запись лога обработки в bot_processing_logs."""
try:
async with async_session() as db:
db.add(BotProcessingLog(
@@ -167,6 +181,7 @@ async def _log_processing(user_id: int, step: str, status: str = "ok", message:
async def _seed_default_settings():
"""Заполнение настроек по умолчанию при первом запуске."""
defaults = {
"assistant_name": "София",
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
@@ -185,6 +200,7 @@ async def _seed_default_settings():
async def _register_webhook():
"""Регистрация webhook-а в MAX Platform."""
try:
subs = await max_api.get_subscriptions()
logger.info(f"Current subscriptions: {subs}")
@@ -199,6 +215,7 @@ async def _register_webhook():
def _verify_secret(request: Request) -> bool:
"""Проверка webhook-секрета (пустой заголовок = OK для обратной совместимости)."""
secret = request.headers.get("X-Max-Bot-Api-Secret", "")
if not secret:
return True
@@ -206,6 +223,7 @@ def _verify_secret(request: Request) -> bool:
async def _get_active_conv_id(user_id: int) -> int | None:
"""Получение ID активного диалога пользователя."""
try:
async with async_session() as db:
result = await db.execute(
@@ -798,6 +816,7 @@ async def api_delete_conversation(conv_id: int):
@app.delete("/api/bot/users/{user_id}")
async def api_delete_bot_user(user_id: int):
"""Каскадное удаление пользователя: BotUser + BotConversation + BotMessage + BotProcessingLog. Заявки остаются."""
from app.models import BotMessage, BotProcessingLog
from sqlalchemy import delete as sa_delete
async with async_session() as db:
+10
View File
@@ -1,3 +1,11 @@
"""
API-клиент для MAX Platform (Мой Мир / VK).
Основные методы:
- send_message: отправка сообщений (с логированием outgoing BotMessage)
- subscribe_webhook: регистрация webhook-а
- answer_callback: ответ на callback-кнопки
"""
import logging
import httpx
from typing import Optional
@@ -9,6 +17,7 @@ TEST_USER_THRESHOLD = 999999000
class MaxAPI:
"""Клиент MAX Platform API с поддержкой mock-реплик для тестов."""
def __init__(self):
self.base_url = settings.max_api_base
self.token = settings.max_token
@@ -65,6 +74,7 @@ class MaxAPI:
notify: bool = True,
conversation_id: int = None,
) -> dict:
"""Отправка сообщения. Если conversation_id — сохраняет outgoing BotMessage."""
if user_id >= TEST_USER_THRESHOLD:
logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}")
self.mock_replies.append(text)
+18
View File
@@ -1,3 +1,20 @@
"""
ORM-модели для PostgreSQL (SQLAlchemy 2.0).
Таблицы:
- bot_settings: настройки бота (ключ-значение)
- bot_features: фичи бота (вкл/выкл)
- bot_categories: категории базы знаний
- bot_knowledge_base: карточки базы знаний
- bot_users: пользователи бота (контакты, согласие)
- bot_conversations: диалоги (state machine)
- bot_messages: сообщения (incoming/outgoing)
- bot_tickets: заявки
- bot_ticket_messages: сообщения заявок
- bot_processing_logs: логи обработки
- bot_ticket_statuses: история смены статусов заявок
- bot_unknown_questions: неизвестные вопросы (автогенерация KB)
"""
from __future__ import annotations
import datetime
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
@@ -53,6 +70,7 @@ class BotKnowledgeBase(Base):
class BotUser(Base):
"""Пользователь бота: профиль, контакты, согласие на обработку ПД."""
__tablename__ = "bot_users"
id = Column(BigInteger, primary_key=True)
first_name = Column(String(255), nullable=True)
+6
View File
@@ -1,3 +1,9 @@
"""
In-memory кэш настроек бота с синхронизацией с БД.
При первом обращении загружает все настройки из bot_settings.
При set() обновляет и БД, и кэш.
"""
import asyncio
from typing import Optional, Dict
from sqlalchemy import select
+13
View File
@@ -1,3 +1,9 @@
"""
YandexGPT интеграция для анализа намерений и генерации ответов.
Модель: yandexgpt-lite
API: https://llm.api.cloud.yandex.net/foundationModels/v1/completion
"""
import httpx
import json
import logging
@@ -14,6 +20,7 @@ YANDEX_GPT_MODEL = "yandexgpt-lite"
class YandexGPT:
"""Клиент YandexGPT для анализа намерений и генерации ответов."""
def __init__(self):
self._api_key: Optional[str] = None
self._folder_id: Optional[str] = None
@@ -27,6 +34,7 @@ class YandexGPT:
return await self._load_credentials()
async def _request(self, prompt: str, system_prompt: str = "", temperature: float = 0.3, max_tokens: int = 1000) -> Optional[str]:
"""Базовый запрос к YandexGPT API."""
if not await self._load_credentials():
return None
@@ -65,11 +73,13 @@ class YandexGPT:
return None
def _sanitize_input(self, text: str) -> str:
"""Очистка и обрезка входного текста (макс. 2000 символов)."""
text = text[:MAX_INPUT_LENGTH]
text = text.replace("\r\n", "\n").replace("\r", "\n")
return text
async def analyze_intent(self, text: str, history_context: str = "") -> str:
"""Анализ намерения: ticket/question/unknown. Aggressive ticket detection для услуг."""
safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = (
@@ -97,6 +107,7 @@ class YandexGPT:
return "unknown"
async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]:
"""Генерация ответа на вопрос с контекстом из базы знаний."""
safe_question = self._sanitize_input(question)
safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = (
@@ -116,6 +127,7 @@ class YandexGPT:
return await self._request(user_prompt, system_prompt)
async def clarify_intent(self, text: str, history_context: str = "") -> str:
"""Уточнение намерения: если есть слова-маркеры — отвечает без 'уточните'."""
safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = (
@@ -159,6 +171,7 @@ class YandexGPT:
return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?"
async def generate_kb_card(self, user_questions: list) -> Optional[dict]:
"""Генерация карточки базы знаний из вопросов пользователей (JSON)."""
joined = "\n".join(f"- {q}" for q in user_questions)
system_prompt = (
"Ты — редактор базы знаний компании AegisOne Engineering. "