П/ВИН

Портрет целевой аудитории в CRM-мессенджере

·9 мин чтения

Однажды мне написал партнёр по проекту: "брат, нам надо сделать выгрузку - картину по нашей ЦА". Мы к тому моменту накопили реальные переписки с клиентами в crm-messenger, видели чаты, но не понимали, кто эти люди - их возраст, профессию, уровень дохода, мотивы. Именно тогда я впервые всерьёз занялся тем, чтобы собрать портрет целевой аудитории прямо из данных, которые уже лежали у нас внутри системы.

В этой статье расскажу, как мы прошли путь от сырой идеи до полноценной вкладки аналитики с AI-портретами клиентов, публичным дашбордом и системой автоназначения менеджеров на новые лиды. Задач было много, они переплетались, но в итоге сложились в цельную фичу.

Контекст: что такое crm-messenger

CRM-мессенджер — это внутренний инструмент, который объединяет Telegram-переписки с клиентами и CRM-систему (в нашем случае Twenty CRM). Менеджеры видят все диалоги в одном интерфейсе, могут создавать сделки, синхронизировать контакты и управлять воронкой продаж прямо из чата.

К моменту, когда мы взялись за аналитику ЦА, в системе накопилось около 616 контактов с историей переписок. Это живые данные — реальные запросы, вопросы, возражения. Золотая жила для понимания аудитории, которая просто лежала мёртвым грузом в базе.

Проблема: данные есть, картины нет

Главная сложность была не техническая, а аналитическая. Данные о клиентах хранились в разных местах:

  • Переписки — в таблице сообщений, неструктурированный текст
  • Контакты — базовые поля: имя, телефон, иногда город
  • Сделки — в Twenty CRM, с суммами и статусами

Никакого единого «портрета» клиента не существовало. Возраст, профессия, уровень дохода — всё это было либо разбросано по тексту переписок, либо вообще нигде не зафиксировано.

Задача звучала так: собрать всю информацию о клиентах и сделках, сформировать портрет по каждому (имя, запрос, возраст, заработок, профессия и всё остальное, что есть), и представить это в табличном и графическом виде.

Решение: трёхслойная архитектура данных

Слой 1: Новая таблица audience_profiles

Первым делом написали миграцию — новая таблица crm_messenger.audience_profiles, один портрет на контакт. Структура простая, но продуманная:

CREATE TABLE crm_messenger.audience_profiles (
  contact_id UUID PRIMARY KEY REFERENCES crm_messenger.contacts(id),
  name TEXT,
  request TEXT,           -- основной запрос клиента
  age INTEGER,
  age_source TEXT,        -- 'exact' | 'estimated'
  income_range TEXT,
  income_source TEXT,
  profession TEXT,
  niche TEXT,
  city TEXT,
  deal_amount NUMERIC,
  raw_llm_output JSONB,   -- сырой ответ LLM для отладки
  updated_at TIMESTAMPTZ DEFAULT now()
);

Отдельно сохраняем источник значения (age_source, income_source) — это оказалось критически важным решением, к которому вернёмся ниже.

Слой 2: AI-разбор переписок

Для заполнения портретов написали модуль src/audience.py с цепочкой LLM-провайдеров. Идея простая: берём историю переписки конкретного контакта, скармливаем языковой модели и просим извлечь структурированные данные.

PROMPT_TEMPLATE = """
Проанализируй переписку с клиентом и извлеки информацию:
 
{dialog_text}
 
Верни JSON:
{
  "request": "основной запрос клиента",
  "age": null или число,
  "age_estimated": true/false,
  "income_range": null или строка,
  "income_estimated": true/false,
  "profession": null или строка,
  "niche": null или строка
}
 
Если данных нет явно — оцени по контексту и поставь estimated=true.
"""

Цепочка провайдеров: сначала пробуем Claude, при ошибке или таймауте — Gemini. Это дало хорошую надёжность при прогоне по всей базе.

Результат первого прогона: 393 портрета из 616 контактов. У оставшихся 223 меньше двух сообщений от клиента — читать нечего, портрет не строим.

Слой 3: Обогащение из Twenty CRM

AI хорошо извлекает мягкие данные из текста, но жёсткие факты — суммы сделок, должности из CRM-карточки — лучше брать напрямую. Написали функцию list_all_opportunities в src/twenty_crm.py, которая тянет все сделки и матчит их с контактами по ID.

Проверили end-to-end: подставили тестовые данные в CRM — поле подтянулось корректно.

Проблема с оценками возраста

После первого прогона обнаружили неприятную вещь: возраст явно указан лишь у 7 из 393 контактов. По такой выборке никакой статистики не построишь.

Решение — разрешить LLM оценивать возраст по контексту, но явно помечать такие значения. Добавили поле age_source со значениями 'exact' и 'estimated', и переключатель в UI:

const [showEstimated, setShowEstimated] = useState(true);
 
const filteredProfiles = profiles.filter(p => 
  showEstimated || p.age_source === 'exact'
);

Теперь пользователь сам решает: смотреть только на точные данные или включать оценки. В CSV-выгрузке тоже добавили колонку с источником значения.

Нормализация профессий

Первые результаты по профессиям выглядели плохо: «Маркетолог», «маркетолог», «МАРКЕТОЛОГ», «не указана», «Не указана», «—» — всё это считалось разными значениями. Графики превращались в кашу из 50+ уникальных значений.

Написали нормализатор:

def normalize_profession(raw: str | None) -> str | None:
    if not raw:
        return None
    cleaned = raw.strip().lower()
    # Заглушки → None
    if cleaned in ('не указана', 'не указано', '—', '-', 'n/a', ''):
        return None
    # Капитализация первого слова
    return cleaned.capitalize()

После нормализации количество уникальных профессий сократилось с 80+ до ~25 значимых категорий.

Фронтенд: таблица + графики

Вкладка «ЦА» состоит из двух частей: таблица с фильтрами и графики. Агрегаты намеренно считаем на клиенте — так таблица и графики всегда показывают одно и то же, без рассинхрона.

Графики написали без внешних зависимостей — простые SVG-примитивы. Это сознательное решение: не тащить Chart.js или Recharts ради нескольких столбчатых диаграмм.

function BarChart({ data }: { data: Record<string, number> }) {
  const max = Math.max(...Object.values(data));
  return (
    <div className="bar-chart">
      {Object.entries(data).map(([label, value]) => (
        <div key={label} className="bar-row">
          <span className="bar-label">{label}</span>
          <div 
            className="bar-fill"
            style={{ width: `${(value / max) * 100}%` }}
          />
          <span className="bar-value">{value}</span>
        </div>
      ))}
    </div>
  );
}

Анонимная выгрузка и публичный дашборд

Когда вкладка заработала, появился новый запрос: хочу делиться аналитикой с партнёрами, но без контактных данных клиентов.

Сделали два механизма:

1. Переключатель «Анонимно» — убирает имена и контактные поля из таблицы и CSV одновременно. Важно, что обезличивание перенесли на бэкенд — это граница безопасности, а не косметика на фронте.

2. Публичная ссылка на дашборд — отдельный роут без авторизации, который отдаёт только агрегированные данные (графики, распределения) без строк с индивидуальными портретами.

При реализации анонимизации наткнулись на хитрый баг: скраб по имени не ловил транслит и упоминания других людей в тексте. Например, имя «Ирина» в латинской форме «Irina» проходило фильтр. Решили через единую маску имён по всей базе — собираем все имена контактов и заменяем любое вхождение.

Но и тут был подводный камень: слишком агрессивный скраб начал заменять обычные слова. «Контент-автоматизации» превращалось в «Клиент-Клиент», потому что стем «контент» совпадал со стемом имени. Добавили два предохранителя: лимит на длину окончания и отбраковку стемов, которые встречаются в текстах как обычные слова.

Автоназначение менеджеров на новые лиды

Параллельно с аналитикой ЦА решали другую системную проблему: новые лиды приходили без назначенного менеджера, или назначение слетало из-за гонки событий.

Обнаружили три блокера:

BLOCKER 1: Наш же webhook opportunity.created снимал только что назначенного менеджера. Когда создавалась сделка, прилетало событие с ownerId: null, и система послушно обнуляла назначение.

BLOCKER 2: Функция assign_new_lead вызывалась до sync_contact_to_crm, то есть пыталась назначить менеджера на контакт, которого ещё нет в CRM.

BLOCKER 3: Исключения в процессе назначения глотались молча — ошибки не всплывали, лид просто оставался без менеджера.

Фикс BLOCKER 1 — добавили проверку источника события:

async def handle_opportunity_created(payload: dict):
    # Игнорируем события от нашего же бота
    updated_by = payload.get('updatedBy', {}).get('name', '')
    if 'claude' in updated_by.lower() or 'bot' in updated_by.lower():
        return
    
    owner_id = payload.get('ownerId')
    if owner_id is None:
        # Не трогаем назначение — оно уже сделано нашей логикой
        return

Фикс BLOCKER 2 — переставили порядок вызовов во всех точках входа (telegram_service, dialogs, contacts):

# Было:
await assign_new_lead(contact_id)
await sync_contact_to_crm(contact_id)
 
# Стало:
await sync_contact_to_crm(contact_id)
await assign_new_lead(contact_id)  # теперь контакт точно есть в CRM

Round-robin распределение

Сама логика назначения — round-robin по менеджерам с флагом receives_new_leads. Реализовали через атомарный UPDATE с возвратом:

UPDATE crm_messenger.managers
SET last_assigned_at = now()
WHERE id = (
  SELECT id FROM crm_messenger.managers
  WHERE receives_new_leads = true
  ORDER BY last_assigned_at ASC NULLS FIRST
  LIMIT 1
)
RETURNING id, twenty_member_id;

Проверили живым тестом — 6 последовательных вызовов pick_next_manager дали два полных круга: Сергей → Валентин → Эдуард → Сергей → Валентин → Эдуард. Равномерно.

Починка исторических данных

После внедрения автоназначения обнаружили 9 контактов без менеджера — те, что пришли до внедрения фичи. Написали скрипт scripts/assign_unassigned_leads.py для ретроактивного назначения. Скрипт поддерживает --dry-run для проверки без изменений.

После прогона скрипта все 9 контактов получили менеджеров, outbox обработал назначения, CRM обновился. Проверили через API Twenty CRM — ownerId у всех девяти проставлен корректно.

Тесты: от 164 до 213

Вся работа сопровождалась написанием тестов. Итоговый счёт:

  • Было: 164 теста
  • После фикса блокеров: 197 тестов (+33)
  • После автоназначения: 213 тестов (+16)

Для каждого блокера написали тест, который падает без фикса и проходит с ним. Это стандартная практика, но важно проговорить: мы не просто написали тесты «для галочки», а доказали, что каждый тест действительно ловит регрессию.

async def test_opportunity_created_does_not_clear_manager():
    """BLOCKER 1: opportunity.created с ownerId=null не должен снимать менеджера"""
    contact = await create_test_contact(manager_id='test-manager-id')
    
    await handle_webhook({
        'type': 'opportunity.created',
        'ownerId': None,
        'updatedBy': {'name': 'claude code'}
    })
    
    refreshed = await get_contact(contact.id)
    assert refreshed.assigned_manager_id == 'test-manager-id'  # не изменился

Результат

Что получили в итоге:

Вкладка ЦА — живёт на /audience, задеплоена и наполнена. 393 AI-портрета клиентов с данными о профессии, нише, уровне дохода и основном запросе. Табличный и графический вид, фильтры, CSV-выгрузка.

Публичный дашборд — отдельный роут без авторизации для шаринга агрегированной аналитики без контактных данных.

Автоназначение лидов — round-robin по активным менеджерам, без гонок и потерь. 9 исторических контактов ретроактивно получили менеджеров.

213 тестов — все зелёные, каждый блокер покрыт тестом с доказательством регрессии.

Выводы

Главный урок этой итерации — данные без структуры мертвы. У нас было 616 контактов с историей переписок, но никакой аналитики. Один AI-прогон превратил неструктурированный текст в таблицу с 393 портретами. Это не магия — это правильно поставленный промпт и нормализация на выходе.

Второй урок — источник данных важен не меньше самих данных. Когда мы обнаружили, что возраст явно указан только у 7 из 393 клиентов, первым порывом было просто убрать эту колонку. Вместо этого мы разрешили LLM оценивать возраст по контексту, но явно пометили такие значения. Теперь пользователь сам решает, доверять ли оценкам. Прозрачность данных — это фича, а не баг.

Третий урок — граница безопасности должна быть на бэкенде. Когда делали анонимную выгрузку, первый инстинкт был скрыть поля на фронте. Но это иллюзия безопасности — любой может дёрнуть API напрямую. Обезличивание перенесли на бэкенд, и теперь публичный эндпоинт физически не может вернуть контактные данные, даже если фронт сломается.

Четвёртый урок — порядок операций в асинхронном коде критичен. BLOCKER 2 (назначение менеджера до синхронизации с CRM) — классическая ошибка, которую легко пропустить на ревью. Помогло правило: если операция B зависит от результата операции A, они должны идти строго последовательно, и это должно быть явно видно из кода. Никаких «они же почти одновременно».

Пятый урок — ретроактивные скрипты нужны всегда. Когда внедряешь новую логику в работающую систему, всегда есть исторические данные, которые под неё не попали. Скрипт assign_unassigned_leads.py с поддержкой --dry-run — это не опциональная часть задачи, это обязательная.

Технологии и ссылки

Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.