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 typing import List, Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
+6
View File
@@ -1,3 +1,9 @@
"""
Чтение конфигурации из config.php (AegisOne сайт).
Парсит PHP define() константы и возвращает их как dict.
Fallback на hardcoded значения если файл не найден.
"""
from __future__ import annotations from __future__ import annotations
import re import re
import os 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 sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.config import settings from app.config import settings
+5
View File
@@ -1,3 +1,8 @@
"""
Отправка email через sendmail (с fallback на логирование в файл).
Используется для уведомлений о заявках.
"""
import subprocess import subprocess
import os import os
import logging import logging
+20
View File
@@ -1,3 +1,11 @@
"""
Обработчик согласия на обработку персональных данных.
Управляет жизненным циклом согласия:
- запрос согласия → да/нет
- отзыв согласия (consent_revoked_date)
- повторное согласие после отказа
"""
import datetime import datetime
import re import re
import logging import logging
@@ -12,6 +20,12 @@ logger = logging.getLogger(__name__)
def _matches_any(text: str, words: list) -> bool: def _matches_any(text: str, words: list) -> bool:
"""
Проверяет, содержит ли текст хотя бы одно из указанных слов.
Использует split для точного совпадения коротких слов (≤3 символов)
и подстроку для длинных (>3 символов).
"""
text_words = set(text.split()) text_words = set(text.split())
for w in words: for w in words:
if w in text_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: async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
"""Маршрутизация callback-кнопок согласия (да/нет)."""
if callback_id == "consent_yes": if callback_id == "consent_yes":
await handle_consent_yes(user_id, conv_id) await handle_consent_yes(user_id, conv_id)
elif callback_id == "consent_no": 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: async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
"""Обработка текстового ответа на запрос согласия (да/нет/другое)."""
text_lower = text.strip().lower() text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"] consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"] 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: async def handle_consent_yes(user_id: int, conv_id: int) -> None:
"""Обработка согласия: устанавливает consent_given=True, переводит в awaiting_contact."""
async with async_session() as db: async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id)) result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none() 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: async def handle_consent_no(user_id: int, conv_id: int) -> None:
"""Обработка отказа: устанавливает consent_refused, показывает контакты."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id) 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: async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
"""Повторная обработка после отказа: позволяет дать согласие или показывает контакты."""
text_lower = text.strip().lower() text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"] consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"] 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: 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: async with async_session() as db:
user = await db.get(BotUser, user_id) user = await db.get(BotUser, user_id)
if user and user.consent_given: if user and user.consent_given:
+9
View File
@@ -1,3 +1,10 @@
"""
Обработчик контактов пользователя.
Принимает контакты (телефон, email) через:
- sharing контакта из мессенджера (VCF)
- ручной ввод текстом
"""
import datetime import datetime
import re import re
import logging 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: async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
"""Обработка контакта из мессенджера: парсинг телефона/email, переход к заявке."""
async with async_session() as db: async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id)) result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none() 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: async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None:
"""Обработка ручного ввода контакта: валидация телефона/email, отказ → consent_refused."""
text = text.strip() text = text.strip()
text_lower = text.lower() text_lower = text.lower()
+35
View File
@@ -1,3 +1,13 @@
"""
Обработчик приветствий и основных сообщений бота.
Управляет жизненным циклом диалога:
- приветствие → создание пользователя и диалога
- маршрутизация по состояниям (state machine)
- анализ намерений через YandexGPT
- быстрые паттерны (заявка/приветствие) до NLP
- контекст из последних 10 минут
"""
import logging import logging
import datetime import datetime
from typing import Optional from typing import Optional
@@ -7,6 +17,7 @@ from app.database import async_session
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3)) MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now(): def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None) return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
from app.max_api import max_api from app.max_api import max_api
@@ -26,6 +37,7 @@ async def handle_greeting(
last_name: str = "", last_name: str = "",
username: str = "", username: str = "",
) -> None: ) -> None:
"""Обработка приветствия: создаёт/обновляет пользователя, создаёт диалог, отправляет приветствие."""
assistant_name = await settings_cache.get("assistant_name", "София") assistant_name = await settings_cache.get("assistant_name", "София")
greeting_text = ( greeting_text = (
@@ -94,6 +106,14 @@ async def handle_greeting(
async def handle_message(user_id: int, text: str) -> None: 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: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation) 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: async def _get_user_history_context(user_id: int) -> str:
"""Формирует контекст из последних сообщений (10 мин) и открытых заявок пользователя."""
from app.models import BotTicket from app.models import BotTicket
async with async_session() as db: async with async_session() as db:
ten_min_ago = moscow_now() - datetime.timedelta(minutes=10) 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(): async def _select_ai():
"""Выбор AI-провайдера (только YandexGPT)."""
if await yandex_gpt.is_available(): if await yandex_gpt.is_available():
return yandex_gpt return yandex_gpt
return None return None
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> 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: try:
logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}") 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: async def _limited_mode(user_id: int, conv_id: int) -> None:
"""Режим без AI: показывает контакты компании."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id) 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: async def _send_contacts(user_id: int, conv_id: int = None) -> None:
"""Отправка контактных данных компании (телефон, email)."""
phones = config_reader.get_phones() phones = config_reader.get_phones()
email = config_reader.get_site_mail() 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: async def _get_knowledge_base_context(query: str) -> str:
"""Формирует контекст из базы знаний по ключевым словам (ранжирование по score)."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotKnowledgeBase) 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: def _texts_match(text_a: str, text_b: str) -> bool:
"""Проверка схожести двух текстов по пересечению слов (≥50%)."""
stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши", 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): 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]) card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
if not card_data: if not card_data:
return 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: async def _save_unknown_question(user_id: int, question_text: str) -> None:
"""Сохранение неизвестного вопроса с дедупликацией и автогенерацией при 3+ обращениях."""
normalized_input = question_text.lower().strip() normalized_input = question_text.lower().strip()
async with async_session() as db: async with async_session() as db:
+7
View File
@@ -1,3 +1,8 @@
"""
Обработчик callback-кнопок и шаринга контактов.
Маршрутизирует callback-и по типу (consent_* и др.).
"""
import datetime import datetime
import logging import logging
from sqlalchemy import select 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: async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None:
"""Маршрутизация callback-кнопок (consent_* → consent handler)."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation) select(BotConversation)
@@ -40,6 +46,7 @@ async def handle_contact_share(
vcf_data: dict = None, vcf_data: dict = None,
contact_hash: str = "", contact_hash: str = "",
) -> None: ) -> None:
"""Обработка шаринга контакта из мессенджера: парсинг VCF, сохранение в BotUser."""
if vcf_data is None: if vcf_data is None:
vcf_data = {} vcf_data = {}
async with async_session() as db: async with async_session() as db:
+8
View File
@@ -1,3 +1,9 @@
"""
Обработчик заявок (inquiry).
Создаёт BotTicket с контактными данными пользователя,
отправляет уведомление на email, подтверждает пользователю.
"""
import datetime import datetime
import logging import logging
from sqlalchemy import select from sqlalchemy import select
@@ -12,10 +18,12 @@ logger = logging.getLogger(__name__)
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3)) MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now(): def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None) return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None: async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
"""Обработка заявки: создаёт.ticket, отправляет email, подтверждает пользователю."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id) 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 from typing import List, Dict, Any
POLITICA_URL = "https://aegisone.ru/politica.php" 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 from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -54,6 +62,7 @@ app.add_middleware(
async def _apply_migrations(): async def _apply_migrations():
"""Автоматические миграции: добавление колонок/таблиц при старте."""
async with engine.begin() as conn: async with engine.begin() as conn:
try: try:
await conn.execute(text(""" 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): async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str):
"""Безопасное добавление колонки (idempotent)."""
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
_validate_sql_name(table) _validate_sql_name(table)
_validate_sql_name(column) _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): async def _create_table_if_not_exists(conn, table: str, create_sql: str):
"""Безопасное создание таблицы (idempotent)."""
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
try: try:
await conn.execute(sa_text(f""" 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: def _validate_sql_name(name: str) -> None:
"""Валидация SQL-идентификатора (только буквы, цифры, подчёркивания)."""
import re import re
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name): if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name):
raise ValueError(f"Invalid SQL identifier: {name}") raise ValueError(f"Invalid SQL identifier: {name}")
def _validate_sql_type(col_type: str) -> None: def _validate_sql_type(col_type: str) -> None:
"""Валидация SQL-типа колонки (разрешённые типы)."""
import re import re
allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$' allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
if not re.match(allowed, col_type.strip()): 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): 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: try:
async with async_session() as db: async with async_session() as db:
db.add(BotProcessingLog( 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(): async def _seed_default_settings():
"""Заполнение настроек по умолчанию при первом запуске."""
defaults = { defaults = {
"assistant_name": "София", "assistant_name": "София",
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности", "assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
@@ -185,6 +200,7 @@ async def _seed_default_settings():
async def _register_webhook(): async def _register_webhook():
"""Регистрация webhook-а в MAX Platform."""
try: try:
subs = await max_api.get_subscriptions() subs = await max_api.get_subscriptions()
logger.info(f"Current subscriptions: {subs}") logger.info(f"Current subscriptions: {subs}")
@@ -199,6 +215,7 @@ async def _register_webhook():
def _verify_secret(request: Request) -> bool: def _verify_secret(request: Request) -> bool:
"""Проверка webhook-секрета (пустой заголовок = OK для обратной совместимости)."""
secret = request.headers.get("X-Max-Bot-Api-Secret", "") secret = request.headers.get("X-Max-Bot-Api-Secret", "")
if not secret: if not secret:
return True return True
@@ -206,6 +223,7 @@ def _verify_secret(request: Request) -> bool:
async def _get_active_conv_id(user_id: int) -> int | None: async def _get_active_conv_id(user_id: int) -> int | None:
"""Получение ID активного диалога пользователя."""
try: try:
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
@@ -798,6 +816,7 @@ async def api_delete_conversation(conv_id: int):
@app.delete("/api/bot/users/{user_id}") @app.delete("/api/bot/users/{user_id}")
async def api_delete_bot_user(user_id: int): async def api_delete_bot_user(user_id: int):
"""Каскадное удаление пользователя: BotUser + BotConversation + BotMessage + BotProcessingLog. Заявки остаются."""
from app.models import BotMessage, BotProcessingLog from app.models import BotMessage, BotProcessingLog
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
async with async_session() as db: 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 logging
import httpx import httpx
from typing import Optional from typing import Optional
@@ -9,6 +17,7 @@ TEST_USER_THRESHOLD = 999999000
class MaxAPI: class MaxAPI:
"""Клиент MAX Platform API с поддержкой mock-реплик для тестов."""
def __init__(self): def __init__(self):
self.base_url = settings.max_api_base self.base_url = settings.max_api_base
self.token = settings.max_token self.token = settings.max_token
@@ -65,6 +74,7 @@ class MaxAPI:
notify: bool = True, notify: bool = True,
conversation_id: int = None, conversation_id: int = None,
) -> dict: ) -> dict:
"""Отправка сообщения. Если conversation_id — сохраняет outgoing BotMessage."""
if user_id >= TEST_USER_THRESHOLD: if user_id >= TEST_USER_THRESHOLD:
logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}") logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}")
self.mock_replies.append(text) 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 from __future__ import annotations
import datetime import datetime
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
@@ -53,6 +70,7 @@ class BotKnowledgeBase(Base):
class BotUser(Base): class BotUser(Base):
"""Пользователь бота: профиль, контакты, согласие на обработку ПД."""
__tablename__ = "bot_users" __tablename__ = "bot_users"
id = Column(BigInteger, primary_key=True) id = Column(BigInteger, primary_key=True)
first_name = Column(String(255), nullable=True) first_name = Column(String(255), nullable=True)
+6
View File
@@ -1,3 +1,9 @@
"""
In-memory кэш настроек бота с синхронизацией с БД.
При первом обращении загружает все настройки из bot_settings.
При set() обновляет и БД, и кэш.
"""
import asyncio import asyncio
from typing import Optional, Dict from typing import Optional, Dict
from sqlalchemy import select 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 httpx
import json import json
import logging import logging
@@ -14,6 +20,7 @@ YANDEX_GPT_MODEL = "yandexgpt-lite"
class YandexGPT: class YandexGPT:
"""Клиент YandexGPT для анализа намерений и генерации ответов."""
def __init__(self): def __init__(self):
self._api_key: Optional[str] = None self._api_key: Optional[str] = None
self._folder_id: Optional[str] = None self._folder_id: Optional[str] = None
@@ -27,6 +34,7 @@ class YandexGPT:
return await self._load_credentials() return await self._load_credentials()
async def _request(self, prompt: str, system_prompt: str = "", temperature: float = 0.3, max_tokens: int = 1000) -> Optional[str]: 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(): if not await self._load_credentials():
return None return None
@@ -65,11 +73,13 @@ class YandexGPT:
return None return None
def _sanitize_input(self, text: str) -> str: def _sanitize_input(self, text: str) -> str:
"""Очистка и обрезка входного текста (макс. 2000 символов)."""
text = text[:MAX_INPUT_LENGTH] text = text[:MAX_INPUT_LENGTH]
text = text.replace("\r\n", "\n").replace("\r", "\n") text = text.replace("\r\n", "\n").replace("\r", "\n")
return text return text
async def analyze_intent(self, text: str, history_context: str = "") -> str: async def analyze_intent(self, text: str, history_context: str = "") -> str:
"""Анализ намерения: ticket/question/unknown. Aggressive ticket detection для услуг."""
safe_text = self._sanitize_input(text) safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else "" safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = ( system_prompt = (
@@ -97,6 +107,7 @@ class YandexGPT:
return "unknown" return "unknown"
async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]: async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]:
"""Генерация ответа на вопрос с контекстом из базы знаний."""
safe_question = self._sanitize_input(question) safe_question = self._sanitize_input(question)
safe_history = self._sanitize_input(history_context) if history_context else "" safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = ( system_prompt = (
@@ -116,6 +127,7 @@ class YandexGPT:
return await self._request(user_prompt, system_prompt) return await self._request(user_prompt, system_prompt)
async def clarify_intent(self, text: str, history_context: str = "") -> str: async def clarify_intent(self, text: str, history_context: str = "") -> str:
"""Уточнение намерения: если есть слова-маркеры — отвечает без 'уточните'."""
safe_text = self._sanitize_input(text) safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else "" safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = ( system_prompt = (
@@ -159,6 +171,7 @@ class YandexGPT:
return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?" return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?"
async def generate_kb_card(self, user_questions: list) -> Optional[dict]: async def generate_kb_card(self, user_questions: list) -> Optional[dict]:
"""Генерация карточки базы знаний из вопросов пользователей (JSON)."""
joined = "\n".join(f"- {q}" for q in user_questions) joined = "\n".join(f"- {q}" for q in user_questions)
system_prompt = ( system_prompt = (
"Ты — редактор базы знаний компании AegisOne Engineering. " "Ты — редактор базы знаний компании AegisOne Engineering. "
+9
View File
@@ -1,3 +1,12 @@
"""
Конфигурация service portal (Pydantic Settings).
Читает из .env:
- DATABASE_URL: PostgreSQL URL
- SECRET_KEY: ключ сессий
- SESSION_TTL: время жизни сессии (сек)
- DOCS_DIR/UPLOADS_DIR: пути к документам
"""
import os import os
from pathlib import Path from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
+10
View File
@@ -1,3 +1,13 @@
"""
Главный модуль service portal FastAPI-приложения.
Портал для управления:
- Сотрудниками (owner/engineer/technician)
- Клиентами и объектами
- Задачами и инцидентами
- Настройками бота и базой знаний
- Документами и отчётами
"""
import os import os
import asyncio import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
+10
View File
@@ -1,3 +1,13 @@
"""
ORM-модели для service portal (SQLAlchemy 2.0).
Основные таблицы:
- users: сотрудники (owner/engineer/technician)
- customers: клиенты
- objects: объекты обслуживания
- tasks/ incidents: задачи и инциденты
- blog/cases/ideas: контент
"""
from datetime import datetime from datetime import datetime
from sqlalchemy import ( from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint
+3
View File
@@ -1293,6 +1293,7 @@ BOT_SERVICE_URL = "http://127.0.0.1:8002"
async def _proxy(method: str, path: str, body=None): async def _proxy(method: str, path: str, body=None):
"""Прокси запросов от service portal к max_bot API."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
if body is not None: if body is not None:
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body) resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
@@ -1431,11 +1432,13 @@ async def proxy_bot_conversation_logs(conv_id: int, user: dict = Depends(get_cur
@router.delete("/api/bot/conversations/{conv_id}") @router.delete("/api/bot/conversations/{conv_id}")
async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))): async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))):
"""Удаление диалога (каскадно: сообщения + логи). Доступно только owner."""
return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}") return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}")
@router.delete("/api/bot/users/{user_id}") @router.delete("/api/bot/users/{user_id}")
async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))): async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))):
"""Удаление пользователя бота (каскадно: диалоги + сообщения + логи). Заявки остаются. Доступно только owner."""
return await _proxy("DELETE", f"/api/bot/users/{user_id}") return await _proxy("DELETE", f"/api/bot/users/{user_id}")