# ТЗ: AI Sales Bot v2.0 (Python, standalone) **Версия:** 2.0 **Дата:** 2026-03-29 **Для:** Claude Code / Codex **Язык:** Python 3.11+ **Важно:** Бот работает автономно. НЕ использует OpenClaw, Paperclip или другие платформы. Все промпты, контекст и логика — внутри Python-кода. --- ## 0. ИНСТРУКЦИЯ ДЛЯ AI-РАЗРАБОТЧИКА ### 0.1 Перед началом - Прочитай ВСЁ ТЗ целиком перед написанием первой строки кода - Если что-то неясно — спроси, не додумывай - Каждый модуль должен работать автономно (можно тестировать отдельно) - Пиши тесты ВМЕСТЕ с кодом, не после - Каждая фаза заканчивается работающим тестом ### 0.2 Workflow разработки **ФАЗА 1/5: Ядро + CLI-тест** (можно тестировать в терминале) - Файлы: config.py, core/llm.py, core/privacy.py, core/antispam.py, core/dialog_matrix.py, core/keyword_classifier.py, agents/classifier.py, agents/responder.py, core/trust.py, core/eval.py, core/memory.py, core/router.py - Тест: `python test_cli.py "Сколько стоит дом?"` → правильный ответ - Критерий: 20 тестовых сценариев проходят в CLI **ФАЗА 2/5: Авито webhook** (основной канал) - Файлы: adapters/base.py, adapters/avito_adapter.py, storage/database.py, storage/models.py, tenants/manager.py, tenants/loader.py, main.py - Тест: сообщение в Авито → ответ в Авито - Критерий: бот отвечает на реальные сообщения Авито **ФАЗА 3/5: Квалификация + уведомления владельцу** - Файлы: agents/qualifier.py, agents/escalator.py, core/notifications.py, core/scheduler.py - Тест: HOT-лид → уведомление в Telegram владельцу - Критерий: лиды записываются в БД с правильной категорией **ФАЗА 4/5: Отчёты + команды владельца** - Файлы: reports/reporter.py, adapters/telegram_owner_adapter.py - Тест: /leads, /stats, /update_price работают - Критерий: еженедельный отчёт генерируется **ФАЗА 5/5: Доп. каналы + деплой** - Файлы: adapters/telegram_client_adapter.py, adapters/max_adapter.py, systemd - Тест: бот работает на 2+ каналах одновременно - Критерий: интеграционный тест ### 0.3 Контракты между модулями ``` Adapter → Router: IncomingMessage(channel, tenant_id, chat_id, user_id, user_name, text, media_type, timestamp, raw) → OutgoingMessage(channel, chat_id, text, attachments) | None Router → KeywordClassifier (бесплатно, первый): (text: str) → KeywordResult(intent: str|None, confidence: float) Router → LLM Classifier (платно, если keyword не справился): (text: str, history: list[dict]) → ClassificationResult(intent, confidence, entities) Router → DialogMatrix (бесплатно): (intent: str) → str | None (готовый ответ или None) Router → Responder (платно, если матрица не справилась): (ClassificationResult, tenant: Tenant, history: list[dict], channel: str) → Response(text, facts_used) Router → Evaluator: (Response, tenant: Tenant) → EvalResult(passed: bool, checks: list) Router → Qualifier: (entities: dict, history: list[dict]) → QualificationResult(category: A|B|C|D, collected_data) Router → Escalator: (IncomingMessage, tenant, QualificationResult) → None (side effect: Telegram notification) Router → Notifications: (lead: dict, tenant: Tenant) → None (side effect: Telegram message to owner) ``` ### 0.4 Справочные материалы - **Авито Messenger API:** https://developers.avito.ru/api-catalog/messenger - **MAX Bot API:** https://dev.max.ru/docs — endpoint: https://platform-api.max.ru/ - **DeepSeek API:** https://platform.deepseek.com/api-docs (OpenAI-совместимый) - **OpenRouter API:** https://openrouter.ai/docs (для Qwen fallback) - **aiogram 3.x:** https://docs.aiogram.dev/en/latest/ --- ## 1. ОБЗОР ### 1.1 Что это Мультиканальный AI-бот для автоматизации продаж МСП. Отвечает клиентам за 30 секунд, квалифицирует лиды, передаёт горячих владельцу бизнеса. Работает автономно на VPS без внешних платформ. ### 1.2 Ключевые принципы - **Keyword-first:** 80% ответов без LLM (матрица + regex). LLM только для нестандартных вопросов - **Мульти-тенант:** один инстанс обслуживает N клиентов - **Мультиканальный:** Авито (основной) + Telegram + MAX (через адаптеры) - **Два режима:** Авито = от первого лица владельца / Telegram = ассистент - **Privacy-first:** персданные НЕ уходят в LLM API, только локально ### 1.3 Структура проекта ``` ai-sales-bot/ ├── main.py # Точка входа ├── config.py # Конфигурация (.env) ├── requirements.txt ├── .env.example ├── test_cli.py # CLI-тест (Фаза 1) │ ├── adapters/ # Канальные адаптеры │ ├── __init__.py │ ├── base.py # Базовый адаптер + IncomingMessage/OutgoingMessage │ ├── avito_adapter.py # Авито (webhook, FastAPI) │ ├── telegram_owner_adapter.py # Telegram для владельца (уведомления + команды) │ ├── telegram_client_adapter.py # Telegram для клиентов (aiogram) │ └── max_adapter.py # MAX │ ├── core/ # Ядро │ ├── __init__.py │ ├── router.py # Главный оркестратор │ ├── llm.py # DeepSeek + Qwen + шаблонный fallback │ ├── keyword_classifier.py # Keyword-классификатор (без LLM) │ ├── dialog_matrix.py # Матрица готовых ответов (без LLM) │ ├── prompt_builder.py # Конструктор system prompt │ ├── memory.py # История диалогов из БД │ ├── eval.py # Проверка ответов │ ├── trust.py # Trust Matrix │ ├── privacy.py # Фильтрация персданных + regex entities │ ├── antispam.py # Антиспам + rate limiting │ ├── notifications.py # Уведомления владельцу (Telegram) │ ├── scheduler.py # Follow-up + дайджесты │ └── dedup.py # Дедупликация сообщений │ ├── agents/ # AI-агенты (используют LLM) │ ├── __init__.py │ ├── classifier.py # LLM-классификатор (fallback) │ ├── responder.py # Генерация ответов через LLM │ ├── qualifier.py # Квалификация лидов (логика в коде, не LLM) │ └── escalator.py # Эскалация + статус диалога │ ├── tenants/ # Мульти-тенант │ ├── __init__.py │ ├── manager.py # Управление клиентами │ ├── loader.py # Загрузка данных клиента │ └── onboarding.py # Автоонбординг через Telegram │ ├── storage/ # Хранение │ ├── __init__.py │ ├── database.py # SQLite │ ├── models.py # Таблицы │ └── queue.py # Очередь сообщений при недоступности API │ ├── reports/ # Отчёты │ ├── __init__.py │ └── reporter.py │ ├── data/ # Данные клиентов │ ├── _template/ # Шаблон нового клиента │ │ ├── config.yaml │ │ ├── company_profile.md │ │ ├── price_list.md │ │ ├── faq.md │ │ ├── portfolio.md │ │ ├── guarantees.md │ │ ├── objection_handlers.md │ │ ├── escalation_rules.md │ │ ├── dialog_matrix.yaml # Матрица в YAML (парсится) │ │ └── tone_of_voice.md │ └── {tenant_id}/ │ ├── tests/ │ ├── test_classifier.py │ ├── test_responder.py │ ├── test_qualifier.py │ ├── test_trust.py │ ├── test_matrix.py │ └── test_scenarios.py # 20 сценариев │ └── scripts/ ├── add_tenant.py ├── backup.sh └── init_db.py ``` --- ## 2. ГЛАВНАЯ ЛОГИКА: ROUTER ### 2.1 Порядок обработки сообщения ```python async def process_message(message: IncomingMessage) -> OutgoingMessage | None: # 0. Дедупликация if dedup.is_duplicate(message): return None # 1. Определить tenant tenant = tenant_manager.get_by_channel(message.channel, message.user_id) if not tenant: return None # 2. Проверить статус диалога conv = storage.get_conversation(tenant.id, message.chat_id) if conv and conv.status == "escalated": # Бот молчит, пересылает менеджеру await notifications.forward_to_owner(message, tenant) return None # 3. Антиспам if antispam.is_spam(message.text, message.user_id): return None # 4. Обработка медиа (фото, голосовые, документы) if message.media_type != "text": return handle_media(message, tenant) # 5. Проверить рабочие часы if not is_working_hours(tenant): return OutgoingMessage(text=f"Добрый день! Ответим вам с {tenant.working_hours_start}. " f"Если срочно — звоните {tenant.phone}") # 6. Приветствие (первое сообщение в диалоге) greeting = "" if is_first_message(tenant.id, message.chat_id): greeting = get_greeting_by_time(tenant.timezone) + " " # 7. Извлечь entities regex-ом (бесплатно, до LLM) entities = privacy.extract_entities(message.text) # 8. Сохранить контакты локально (если есть телефон/email) if entities.get("phone") or entities.get("email"): storage.save_contact(message, entities, tenant.id) # 9. Очистить персданные из текста ПЕРЕД отправкой в LLM clean_text = privacy.strip_personal_data(message.text) # 10. Keyword-классификация (бесплатно) kw_result = keyword_classifier.classify(clean_text) # 11. Если keyword уверен → ответ из матрицы (0 токенов) if kw_result.intent and kw_result.confidence > 0.9: matrix_response = dialog_matrix.get_response(kw_result.intent, tenant.id) if matrix_response: response_text = greeting + matrix_response storage.save_message(message, response_text, kw_result.intent) # Квалификация (после 3+ сообщений) await maybe_qualify(message, entities, tenant) return OutgoingMessage(channel=message.channel, chat_id=message.chat_id, text=truncate(response_text, message.channel)) # 12. Keyword не справился → LLM-классификатор history = memory.get_history_as_messages(tenant.id, message.chat_id, limit=10) classification = await classifier.classify(clean_text, history) # 13. Проверить матрицу ещё раз (по LLM-intent) matrix_response = dialog_matrix.get_response(classification.intent, tenant.id) if matrix_response and classification.confidence > 0.8: response_text = greeting + matrix_response storage.save_message(message, response_text, classification.intent) await maybe_qualify(message, entities, tenant) return OutgoingMessage(channel=message.channel, chat_id=message.chat_id, text=truncate(response_text, message.channel)) # 14. Матрица не справилась → LLM-ответ # Загрузить контекст объявления (для Авито) item_context = "" if message.channel == "avito" and message.raw.get("item_id"): item_context = await avito_adapter.get_item_text(message.raw["item_id"], tenant) response = await responder.respond( classification=classification, tenant=tenant, history=history, channel=message.channel, item_context=item_context ) # 15. Eval pipeline eval_result = await evaluator.check(response, tenant) if not eval_result.passed: response_text = greeting + "Уточню информацию и вернусь с ответом!" await escalator.escalate(message, tenant, reason="eval_failed") storage.save_message(message, response_text, classification.intent, eval_failed=True) return OutgoingMessage(channel=message.channel, chat_id=message.chat_id, text=response_text) # 16. Сохранить и ответить response_text = greeting + response.text storage.save_message(message, response_text, classification.intent) # 17. Квалификация await maybe_qualify(message, entities, tenant) return OutgoingMessage(channel=message.channel, chat_id=message.chat_id, text=truncate(response_text, message.channel)) ``` ### 2.2 Лимиты длины по каналам ```python CHANNEL_LIMITS = { "avito": 1000, "telegram": 4096, "max": 4000, } def truncate(text: str, channel: str) -> str: limit = CHANNEL_LIMITS.get(channel, 1000) if len(text) > limit: return text[:limit - 3] + "..." return text ``` ### 2.3 Рабочие часы ```python def is_working_hours(tenant) -> bool: tz = pytz.timezone(tenant.timezone) now = datetime.now(tz) return tenant.working_hours_start <= now.hour < tenant.working_hours_end ``` ### 2.4 Обработка медиа ```python def handle_media(message: IncomingMessage, tenant) -> OutgoingMessage: MEDIA_RESPONSES = { "photo": "Спасибо за фото! Подскажите, какой у вас вопрос?", "voice": "Напишите текстом — так удобнее для расчёта.", "document": "Получил документ. Оставьте номер — обсудим по телефону.", "sticker": None, # Игнорировать "video": "Спасибо! Подскажите, какой у вас вопрос?", } text = MEDIA_RESPONSES.get(message.media_type) if text is None: return None return OutgoingMessage(channel=message.channel, chat_id=message.chat_id, text=text) ``` ### 2.5 Приветствие по времени ```python def get_greeting_by_time(timezone: str) -> str: tz = pytz.timezone(timezone) hour = datetime.now(tz).hour if 6 <= hour < 12: return "Доброе утро." elif 12 <= hour < 18: return "Добрый день." elif 18 <= hour < 23: return "Добрый вечер." else: return "Здравствуйте." ``` --- ## 3. KEYWORD-КЛАССИФИКАТОР (без LLM) ### 3.1 core/keyword_classifier.py ```python KEYWORD_MAP = { "PRICE_GENERAL": ["сколько стоит", "какая цена", "почём", "прайс", "расценки", "стоимость"], "PRICE_SPECIFIC": ["м²", "квадрат", "площад"], "TIMELINE": ["сколько строит", "какие сроки", "как долго", "когда построит", "сколько времени"], "MATERIALS": ["из чего строит", "какой материал", "газобетон", "кирпич", "блок"], "PORTFOLIO": ["покажите работы", "примеры", "фото домов", "портфолио"], "GUARANTEE": ["гарантия", "гарантию"], "LOCATION": ["где строит", "какой регион", "калининград", "область"], "MEETING": ["встрет", "замер", "приехать", "посмотреть", "приезжай"], "OBJECTION_PRICE": ["дорого", "дешевле", "скидк"], "OBJECTION_TRUST": ["надёжн", "обман", "кидал", "отзыв"], "COMPLAINT": ["жалоб", "прокуратур", "суд", "обманщик"], "SPAM": ["продам", "оптом", "реклам", "заработок", "криптовалют", "инвестиц"], "CONTACT": ["позвон", "телефон", "номер", "whatsapp", "telegram", "связ"], "PROJECT": ["проект", "чертёж", "план дома"], } def classify(text: str) -> KeywordResult: text_lower = text.lower() for intent, keywords in KEYWORD_MAP.items(): for kw in keywords: if kw in text_lower: return KeywordResult(intent=intent, confidence=0.95) return KeywordResult(intent=None, confidence=0.0) ``` --- ## 4. МАТРИЦА ДИАЛОГОВ (без LLM) ### 4.1 core/dialog_matrix.py Загружает `data/{tenant_id}/dialog_matrix.yaml` при старте. ### 4.2 Формат dialog_matrix.yaml (пример для стройки) ```yaml # Матрица готовых ответов. Бот отвечает БЕЗ вызова LLM. # Формат: intent → текст ответа (1-2 предложения, от первого лица) PRICE_GENERAL: "Зависит от площади, материала и проекта. Какой площади дом планируете?" PRICE_SPECIFIC: "Считаю под конкретный проект. Расскажите подробнее — рассчитаю." TIMELINE: "Зависит от объёма. Расскажите что планируете — сориентирую по срокам." MATERIALS: "Работаю с газобетоном, кирпичом, блоками. Какой площади дом?" PORTFOLIO: "Могу показать примеры. Оставьте номер — отправлю фото готовых объектов." GUARANTEE: "Да, работаю по договору с гарантией. Детали обсудим по телефону." LOCATION: "Работаю по Калининграду и области. Где ваш участок?" MEETING: "Да, выезжаю на замер. Где находится участок?" OBJECTION_PRICE: "Цена зависит от объёма и материалов. Могу пересчитать с другими параметрами." OBJECTION_TRUST: "Работаю более 10 лет, всё по договору. Оставьте номер — покажу объекты." CONTACT: "Оставьте номер — перезвоню." PROJECT: "Помогу с этим. Оставьте номер — обсудим варианты." COMPLAINT: "Если есть вопрос по строительству — готов ответить." SPAM: null # Не отвечать # После получения контакта CONTACT_RECEIVED: "Принял. Перезвоню." # Не по теме UNKNOWN: "Я занимаюсь строительством. Чем могу помочь?" # "Ты бот?" BOT_QUESTION: "Отвечаю быстро, чтобы вы не ждали. Чем могу помочь?" # Квалификационные вопросы (бот задаёт сам) qualification_chain: - "Где находится участок?" - "Какой площади планируете дом?" - "Есть готовый проект?" - "Когда планируете начать?" ``` --- ## 5. PRIVACY (фильтрация персданных) ### 5.1 core/privacy.py ```python import re def extract_entities(text: str) -> dict: """Извлечь сущности regex-ом ДО отправки в LLM""" entities = {} # Телефон phone = re.search(r'[\+]?[78][\s\-\(]?\d{3}[\s\-\)]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}', text) if phone: entities["phone"] = phone.group() # Email email = re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', text) if email: entities["email"] = email.group() # Площадь area = re.search(r'(\d{2,3})\s*(?:м²|м2|кв\.?\s*м|квадрат)', text) if area: entities["area"] = int(area.group(1)) # Бюджет budget = re.search(r'(\d+(?:[.,]\d+)?)\s*(?:млн|миллион)', text) if budget: entities["budget"] = budget.group(1) return entities def strip_personal_data(text: str) -> str: """Заменить персданные на [ТЕЛЕФОН]/[EMAIL] перед отправкой в LLM""" cleaned = re.sub(r'[\+]?[78][\s\-\(]?\d{3}[\s\-\)]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}', '[ТЕЛЕФОН]', text) cleaned = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[EMAIL]', cleaned) return cleaned ``` --- ## 6. LLM (DeepSeek + Qwen + fallback) ### 6.1 core/llm.py ```python class LLMClient: FALLBACK_RESPONSE = "Спасибо за обращение! Напишите подробнее — отвечу." TIMEOUT = 15 # секунд async def complete(self, messages: list, temperature: float = 0.3, max_tokens: int = 300) -> str: # Уровень 1: DeepSeek try: return await self._call_deepseek(messages, temperature, max_tokens) except Exception as e: logger.warning(f"DeepSeek failed: {e}") # Уровень 2: Qwen через OpenRouter try: return await self._call_openrouter(messages, temperature, max_tokens) except Exception as e: logger.warning(f"OpenRouter failed: {e}") # Уровень 3: Шаблонный ответ logger.critical("All LLMs failed!") return self.FALLBACK_RESPONSE async def _call_deepseek(self, messages, temperature, max_tokens): async with httpx.AsyncClient(timeout=self.TIMEOUT) as client: resp = await client.post( "https://api.deepseek.com/v1/chat/completions", headers={"Authorization": f"Bearer {config.DEEPSEEK_API_KEY}"}, json={ "model": "deepseek-chat", "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"].strip() async def _call_openrouter(self, messages, temperature, max_tokens): async with httpx.AsyncClient(timeout=self.TIMEOUT) as client: resp = await client.post( "https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {config.OPENROUTER_API_KEY}"}, json={ "model": "qwen/qwen-2.5-72b-instruct", "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"].strip() ``` --- ## 7. PROMPT BUILDER (конструктор промптов) ### 7.1 core/prompt_builder.py ```python AVITO_MODE = """ РЕЖИМ АВИТО — ты менеджер, говоришь от первого лица. ПРАВИЛА: 1. Говори «я», «делаю», «перезвоню» — НЕ «мы», «наш специалист» 2. Максимум 1-2 предложения 3. Цель — получить номер телефона 4. НЕ представляйся как бот 5. НЕ давай развёрнутых расчётов — «Оставьте номер — рассчитаю» 6. НЕ рекомендуй материалы — «Обсудим по телефону» """ TELEGRAM_MODE = """ РЕЖИМ TELEGRAM — ты ассистент владельца бизнеса. Можешь отвечать подробно, давать рекомендации, показывать таблицы. """ TRUST_RULES = """ ЗАПРЕЩЕНО: - Обещать скидки - Обсуждать юридику - Принимать оплату - Подтверждать факты которых НЕТ в данных компании - Называть точные цены без «точную стоимость рассчитаю после замера» - Называть сроки без «±2 недели в зависимости от условий» Если информации нет в данных — скажи «Уточню и вернусь с ответом» """ SAFETY_RULE = """ БЕЗОПАСНОСТЬ ДАННЫХ: Если клиент оставил телефон, email или адрес — НЕ повторяй их в ответе. Отвечай: «Принял. Перезвоню.» """ def build_system_prompt(tenant, intent: str, channel: str, item_context: str = "") -> str: parts = [] # Режим канала if channel == "avito": parts.append(AVITO_MODE) else: parts.append(TELEGRAM_MODE) # Данные компании (всегда) parts.append(f"КОМПАНИЯ:\n{tenant.company_profile}") parts.append(f"ПРАВИЛА ЭСКАЛАЦИИ:\n{tenant.escalation_rules}") # Контекст объявления (для Авито) if item_context: parts.append(f"ОБЪЯВЛЕНИЕ (клиент пишет из этого объявления):\n{item_context}") # По intent — подгружать только нужное if intent and intent.startswith("PRICE"): parts.append(f"ПРАЙС:\n{tenant.price_list}") elif intent == "PORTFOLIO": parts.append(f"ПОРТФОЛИО:\n{tenant.portfolio}") elif intent and intent.startswith("OBJECTION"): parts.append(f"ВОЗРАЖЕНИЯ:\n{tenant.objection_handlers}") elif intent == "GUARANTEE": parts.append(f"ГАРАНТИИ:\n{tenant.guarantees}") else: parts.append(f"FAQ:\n{tenant.faq}") # Trust + Safety (всегда) parts.append(TRUST_RULES) parts.append(SAFETY_RULE) return "\n---\n".join(parts) ``` --- ## 8. LLM-КЛАССИФИКАТОР (fallback) ### 8.1 agents/classifier.py Вызывается ТОЛЬКО если keyword_classifier вернул confidence < 0.9. **Промпт:** ``` Определи intent сообщения клиента строительной компании. Выбери ОДИН из: PRICE_GENERAL, PRICE_SPECIFIC, TIMELINE, MATERIALS, PORTFOLIO, GUARANTEE, LOCATION, MEETING, OBJECTION_PRICE, OBJECTION_TRUST, OBJECTION_TIMELINE, FINANCING, PROCESS, DOCUMENTS, LAND, COMPARISON, SEASONAL, COMPLAINT, SPAM, CONTACT, PROJECT, BOT_QUESTION, UNKNOWN Ответ строго JSON: {"intent": "...", "confidence": 0.XX} ``` **Параметры:** temperature=0.1, max_tokens=100 --- ## 9. КВАЛИФИКАЦИЯ ЛИДОВ (в коде, не LLM) ### 9.1 agents/qualifier.py ```python def qualify_lead(entities: dict, history: list, intents: list) -> QualificationResult: score = 0 if entities.get("phone"): score += 3 if entities.get("area"): score += 2 if entities.get("budget"): score += 2 if "MEETING" in intents: score += 3 if "PROJECT" in intents: score += 1 if len(history) >= 6: score += 1 if score >= 5: return QualificationResult(category="A", action="notify_owner_immediately") elif score >= 3: return QualificationResult(category="B", action="follow_up_24h") elif score >= 1: return QualificationResult(category="C", action="standard_response") else: return QualificationResult(category="D", action="close_politely") ``` --- ## 10. ЭСКАЛАЦИЯ + СТАТУС ДИАЛОГА ### 10.1 agents/escalator.py **Статусы диалога:** ``` active → бот обрабатывает escalated → бот молчит, пересылает владельцу returned → владелец вернул боту closed → диалог завершён ``` **Триггеры эскалации:** - Intent = COMPLAINT - Intent = UNKNOWN (3 раза подряд) - Eval failed - Категория лида = A (HOT) - Ключевые слова: «суд», «жалоба», «прокуратура» **Формат уведомления:** ``` 🔥 Горячий лид! Канал: Авито Объявление: {item_title} Телефон: {phone} Площадь: {area} Бюджет: {budget} Последние сообщения: — Клиент: {msg1} — Бот: {reply1} — Клиент: {msg2} ``` **Таймер:** Если владелец не подтвердил контакт за 2 часа → повторное уведомление. Через 24ч → бот сам пишет клиенту: «Извините за задержку, менеджер свяжется с вами сегодня.» --- ## 11. МУЛЬТИ-ТЕНАНТ ### 11.1 Конфиг клиента (data/{tenant_id}/config.yaml) ```yaml tenant_id: "stroydream" company_name: "Александр — строительство" active: true channels: avito: enabled: true client_id: "AVITO_CLIENT_ID" client_secret: "AVITO_CLIENT_SECRET" user_id: 254744296 telegram_client: enabled: false bot_token: "" max: enabled: false owner: telegram_chat_id: "1036902910" name: "Александр" phone: "+79001234567" settings: timezone: "Europe/Kaliningrad" working_hours_start: 8 working_hours_end: 20 tone: "formal" max_response_length: 500 ``` ### 11.2 Маппинг tenant по каналам ```python # tenants/manager.py def get_by_channel(self, channel: str, identifier) -> Tenant: if channel == "avito": # По Авито user_id из webhook payload return self._avito_map.get(identifier) elif channel == "telegram": # По bot_token return self._telegram_map.get(identifier) ``` ### 11.3 Авито webhook роутинг Один endpoint на всех: `POST /avito/webhook` Определяем tenant по `user_id` из payload. --- ## 12. ХРАНЕНИЕ (SQLite) ### 12.1 Таблицы ```sql CREATE TABLE conversations ( id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, channel TEXT NOT NULL, chat_id TEXT NOT NULL, user_id TEXT, user_name TEXT, item_id TEXT, status TEXT DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE messages ( id INTEGER PRIMARY KEY, conversation_id INTEGER REFERENCES conversations(id), direction TEXT NOT NULL, -- IN / OUT text TEXT NOT NULL, intent TEXT, confidence REAL, response_time_ms INTEGER, -- Время ответа eval_passed BOOLEAN DEFAULT TRUE, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE leads ( id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, conversation_id INTEGER REFERENCES conversations(id), category TEXT DEFAULT 'D', -- A/B/C/D name TEXT, phone TEXT, -- Шифрованный (Fernet) email TEXT, -- Шифрованный area TEXT, budget TEXT, item_id TEXT, status TEXT DEFAULT 'NEW', -- NEW/CONTACTED/CLOSED/LOST owner_notified_at TIMESTAMP, owner_confirmed_at TIMESTAMP, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE metrics ( id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, date DATE NOT NULL, total_messages INTEGER DEFAULT 0, total_conversations INTEGER DEFAULT 0, leads_a INTEGER DEFAULT 0, leads_b INTEGER DEFAULT 0, leads_c INTEGER DEFAULT 0, leads_d INTEGER DEFAULT 0, escalations INTEGER DEFAULT 0, eval_failures INTEGER DEFAULT 0, avg_response_time_ms INTEGER DEFAULT 0, llm_calls INTEGER DEFAULT 0, matrix_hits INTEGER DEFAULT 0 ); -- Индексы CREATE INDEX idx_messages_chat ON messages(conversation_id, timestamp); CREATE INDEX idx_leads_tenant ON leads(tenant_id, created_at); CREATE INDEX idx_conversations_chat ON conversations(tenant_id, chat_id); -- Очередь (при недоступности API) CREATE TABLE message_queue ( id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, channel TEXT NOT NULL, chat_id TEXT NOT NULL, text TEXT NOT NULL, attempts INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` --- ## 13. SCHEDULER (follow-up + дайджесты) ### 13.1 core/scheduler.py ```python class Scheduler: """Периодические задачи""" async def run(self): while True: await self.check_follow_ups() await self.check_owner_reminders() await self.send_daily_digest() await self.retry_queued_messages() await self.cleanup_old_data() await asyncio.sleep(300) # Каждые 5 минут async def check_follow_ups(self): """WARM-лиды без ответа 24ч → follow-up""" # SELECT * FROM leads WHERE category='B' # AND created_at < now() - 24h AND status='NEW' async def check_owner_reminders(self): """HOT-лид, владелец не подтвердил за 2ч → повторное уведомление""" async def send_daily_digest(self): """20:00 по timezone владельца → дайджест в Telegram""" async def retry_queued_messages(self): """Повторная отправка из очереди (API было недоступно)""" async def cleanup_old_data(self): """Удалить персданные старше 90 дней (152-ФЗ)""" ``` --- ## 14. КОМАНДЫ ВЛАДЕЛЬЦА (Telegram) ### 14.1 adapters/telegram_owner_adapter.py ``` /leads — список лидов за сегодня /leads week — за неделю /stats — статистика (диалоги, лиды, конверсия) /update_price — обновить прайс (прислать текст или файл) /update_faq — добавить вопрос в FAQ /pause — приостановить бота /resume — возобновить бота /return {chat_id} — вернуть диалог боту (после эскалации) /close {chat_id} — закрыть диалог ``` --- ## 15. EVAL PIPELINE ### 15.1 core/eval.py ```python class Evaluator: async def check(self, response: Response, tenant: Tenant) -> EvalResult: checks = [] # 1. Запрещённые слова checks.append(("forbidden", self._check_forbidden(response.text))) # 2. Длина ответа (макс 2-3 предложения для Авито) checks.append(("length", len(response.text) < tenant.max_response_length)) # 3. Не говорит «мы», «наш специалист» (для Авито) if response.channel == "avito": checks.append(("first_person", not re.search(r'\b(мы|наш|наша|наше|наши)\b', response.text, re.I))) # 4. Не повторяет персданные checks.append(("no_personal", not re.search(r'\d{10,11}', response.text))) passed = all(ok for _, ok in checks) return EvalResult(passed=passed, checks=checks) FORBIDDEN_PATTERNS = [ r"скидк[аиуе]", r"бесплатно", r"гарантирую", r"100%", r"точная (цена|стоимость)", r"(возврат|вернём) деньг", ] ``` --- ## 16. АНТИСПАМ ### 16.1 core/antispam.py ```python MAX_PER_MINUTE = 5 MAX_PER_HOUR = 30 SPAM_KEYWORDS = ["продам", "оптом", "реклама", "заработок", "криптовалют", "инвестиц", "казино", "ставки", "кредит без"] def is_spam(text: str, user_id: str) -> bool: if get_rate(user_id, minutes=1) > MAX_PER_MINUTE: return True if get_rate(user_id, minutes=60) > MAX_PER_HOUR: return True if any(kw in text.lower() for kw in SPAM_KEYWORDS): return True return False ``` --- ## 17. КОНФИГУРАЦИЯ ### 17.1 .env ```env # LLM (НИКОГДА не в коде!) DEEPSEEK_API_KEY=sk-... OPENROUTER_API_KEY=sk-or-... # БД DATABASE_URL=sqlite:///data/bot.db # Шифрование персданных ENCRYPTION_KEY=... # Fernet key # Admin ADMIN_TELEGRAM_TOKEN=... ADMIN_TELEGRAM_CHAT_ID=... # Логи LOG_LEVEL=INFO LOG_FILE=logs/bot.log ``` ### 17.2 requirements.txt ``` aiogram>=3.4 fastapi>=0.110 uvicorn>=0.29 httpx>=0.27 pydantic>=2.0 pyyaml>=6.0 python-dotenv>=1.0 structlog>=24.0 cryptography>=42.0 pytz>=2024.1 ``` --- ## 18. ТЕСТЫ (20 сценариев) ```python SCENARIOS = [ # Keyword → матрица (0 токенов) {"input": "Сколько стоит дом?", "expect_intent": "PRICE_GENERAL", "expect_llm": False, "expect_contains": "площад"}, {"input": "Покажите работы", "expect_intent": "PORTFOLIO", "expect_llm": False}, {"input": "Дорого у вас", "expect_intent": "OBJECTION_PRICE", "expect_llm": False}, # Regex entities {"input": "Позвоните мне 89001234567", "expect_entity": "phone", "expect_response": "Принял. Перезвоню."}, {"input": "Хочу дом 120 м²", "expect_entity_area": 120}, # Trust Matrix {"input": "Дайте скидку 20%", "expect_not_contains": "скидк"}, {"input": "Вы строите за 2 недели, верно?", "expect_not_contains": "да", "comment": "Не подтверждать непроверенные факты"}, # Privacy {"input": "Мой номер +79001234567", "expect_llm_input_not_contains": "+7900", "comment": "Телефон не уходит в LLM"}, # Квалификация {"history": ["Хочу дом 150м²", "Бюджет 8 млн", "Позвоните: 89001234567"], "expect_category": "A"}, {"history": ["Сколько стоит?"], "expect_category": "D"}, # Медиа {"input_media": "photo", "expect_contains": "фото"}, {"input_media": "voice", "expect_contains": "текстом"}, # Спам {"input": "Продам кирпич оптом дёшево", "expect_no_response": True}, # "Ты бот?" {"input": "Это бот отвечает?", "expect_not_contains": "бот", "expect_contains": "помочь"}, # Рабочие часы {"input": "Привет", "time": "03:00", "expect_contains": "Ответим"}, # Каркасные (не делаем) {"input": "Строите каркасные?", "expect_contains": "не строю"}, # Eval {"input": "Какая точная цена?", "expect_not_contains": "точная цена"}, # Follow-up (async) {"scenario": "warm_no_response_24h", "expect_follow_up": True}, # Эскалация {"input": "Позвоню в прокуратуру!", "expect_escalation": True}, # Дедупликация {"input": "Привет", "repeat": True, "expect_single_response": True}, ] ``` --- ## 19. ДЕПЛОЙ ### 19.1 systemd ```ini [Unit] Description=AI Sales Bot After=network.target [Service] Type=simple User=bot WorkingDirectory=/opt/ai-sales-bot ExecStart=/opt/ai-sales-bot/venv/bin/python main.py Restart=always RestartSec=10 EnvironmentFile=/opt/ai-sales-bot/.env [Install] WantedBy=multi-user.target ``` ### 19.2 Бэкап (cron) ```bash # Ежедневно 03:00 0 3 * * * /opt/ai-sales-bot/scripts/backup.sh # backup.sh #!/bin/bash DATE=$(date +%Y-%m-%d) cp /opt/ai-sales-bot/data/bot.db /opt/ai-sales-bot/backups/bot_${DATE}.db ls -t /opt/ai-sales-bot/backups/ | tail -n +31 | xargs rm -f ``` ### 19.3 Graceful shutdown ```python # main.py async def shutdown(): logger.info("Shutting down...") await scheduler.stop() await avito_adapter.stop() storage.close() logger.info("Shutdown complete") signal.signal(signal.SIGTERM, lambda *_: asyncio.create_task(shutdown())) ``` --- *ТЗ v2.0 — 2026-03-29* *Путь: /root/.openclaw/workspace/projects/ai-agency/TECHNICAL-SPEC-v2.md*