v1.8.3: добавлены docstrings и комментарии по AGENTS.md ко всем ключевым файлам
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
Чтение конфигурации из config.php (AegisOne сайт).
|
||||
|
||||
Парсит PHP define() константы и возвращает их как dict.
|
||||
Fallback на hardcoded значения если файл не найден.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import os
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
"""
|
||||
Отправка email через sendmail (с fallback на логирование в файл).
|
||||
|
||||
Используется для уведомлений о заявках.
|
||||
"""
|
||||
import subprocess
|
||||
import os
|
||||
import logging
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
In-memory кэш настроек бота с синхронизацией с БД.
|
||||
|
||||
При первом обращении загружает все настройки из bot_settings.
|
||||
При set() обновляет и БД, и кэш.
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Optional, Dict
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
"""
|
||||
Конфигурация service portal (Pydantic Settings).
|
||||
|
||||
Читает из .env:
|
||||
- DATABASE_URL: PostgreSQL URL
|
||||
- SECRET_KEY: ключ сессий
|
||||
- SESSION_TTL: время жизни сессии (сек)
|
||||
- DOCS_DIR/UPLOADS_DIR: пути к документам
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
"""
|
||||
Главный модуль service portal FastAPI-приложения.
|
||||
|
||||
Портал для управления:
|
||||
- Сотрудниками (owner/engineer/technician)
|
||||
- Клиентами и объектами
|
||||
- Задачами и инцидентами
|
||||
- Настройками бота и базой знаний
|
||||
- Документами и отчётами
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -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 sqlalchemy import (
|
||||
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint
|
||||
|
||||
@@ -1293,6 +1293,7 @@ BOT_SERVICE_URL = "http://127.0.0.1:8002"
|
||||
|
||||
|
||||
async def _proxy(method: str, path: str, body=None):
|
||||
"""Прокси запросов от service portal к max_bot API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
if body is not None:
|
||||
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}")
|
||||
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}")
|
||||
|
||||
|
||||
@router.delete("/api/bot/users/{user_id}")
|
||||
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}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user