docs(autocall): HTML-версия презентации из общего файла содержания (deck_content.py), проверка совпадения текста с PDF
This commit is contained in:
parent
ed89222834
commit
d9ded99e81
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""Содержание презентации «Автодозвон: как это работает».
|
||||||
|
|
||||||
|
Один источник правды: и PDF (how-it-works.py), и HTML (render-html.py) собираются
|
||||||
|
отсюда, поэтому версии не разъезжаются при правках.
|
||||||
|
|
||||||
|
Факты сверены с кодом: scripts/autocall_scheduler.py, docs/ats/ss-dialog.agi,
|
||||||
|
supabase/functions/autocall-result/index.ts и настройками боевой системы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACCENT, ACCENT2, WARN, DANGER = "accent", "accent2", "warn", "danger"
|
||||||
|
|
||||||
|
TITLE_SLIDE = {
|
||||||
|
"kind": "title",
|
||||||
|
"kicker": "СУПЕР САМ · ДОСТАВКА И САМОВЫВОЗ",
|
||||||
|
"title": "Автодозвон: как это работает",
|
||||||
|
"sub": "Робот звонит клиенту, согласует день и половину дня — без участия оператора",
|
||||||
|
"lines": [
|
||||||
|
"Что описано: правила звонка, паузы, поведение в нештатных случаях (не дозвонились,",
|
||||||
|
"сбросили трубку, не нажали цифру), где что настраивается и что видит менеджер.",
|
||||||
|
"",
|
||||||
|
"Основано на текущем коде и настройках боевой системы, без обещаний и продажи.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SLIDES = [
|
||||||
|
{
|
||||||
|
"kind": "cards2full",
|
||||||
|
"title": "Что делает робот",
|
||||||
|
"sub": "Коротко: он не продаёт и не консультирует — только согласует день и половину дня",
|
||||||
|
"cards": [
|
||||||
|
{"title": "Три задачи", "accent": ACCENT, "items": [
|
||||||
|
"Дозвониться, когда заказ готов и клиент не подтвердил день по ссылке из SMS",
|
||||||
|
"Предложить конкретные дни голосом и получить выбор цифрой",
|
||||||
|
"Записать выбор в заказ — дату, половину дня и признак «согласовано роботом»",
|
||||||
|
]},
|
||||||
|
{"title": "Чего он не делает", "accent": WARN, "items": [
|
||||||
|
"Не соединяет с живым менеджером — такой цифры в диалоге нет",
|
||||||
|
"Не спорит и не уговаривает: нет ответа — повторит по расписанию",
|
||||||
|
"Не предлагает нерабочие дни и не звонит вне рабочего времени",
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"full": {"title": "Как это выглядит для клиента", "accent": ACCENT2, "items": [
|
||||||
|
"Звонок с одного номера, голос один и тот же: «Ваш заказ готов. Выберите день и время»",
|
||||||
|
"Клиент нажимает цифру дня и цифру половины дня — робот подтверждает выбор вслух",
|
||||||
|
"После подтверждения даёт возможность изменить выбор (нажатием цифры 2)",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "steps",
|
||||||
|
"title": "Путь заказа до звонка",
|
||||||
|
"sub": "Когда именно звучит звонок",
|
||||||
|
"steps": [
|
||||||
|
("1. Заказ готов", "менеджер отмечает\nготовность в системе"),
|
||||||
|
("2. SMS со ссылкой", "клиент может выбрать\nдень сам, без звонка"),
|
||||||
|
("3. Пауза 90 минут", "настройка админки:\nстолько ждём ответа"),
|
||||||
|
("4. Звонок робота", "если клиент не\nподтвердил по ссылке"),
|
||||||
|
("5. Результат в заказ", "дата и половина дня\nв самом заказе"),
|
||||||
|
],
|
||||||
|
"full": {"title": "Важно", "accent": WARN, "items": [
|
||||||
|
"Если клиент подтвердил день по ссылке сам — звонка не будет: заказ уже согласован",
|
||||||
|
"Звонок идёт только в рабочее время и только в рабочие дни (настройки кампании)",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "cards2full",
|
||||||
|
"title": "Откуда берутся дни и время",
|
||||||
|
"sub": "Админка → Расписание → Рабочие дни: один источник правды",
|
||||||
|
"cards": [
|
||||||
|
{"title": "Что настраивается", "accent": ACCENT, "items": [
|
||||||
|
"Рабочие дни склада (самовывоз) и рабочие дни доставки",
|
||||||
|
"Часы: открытие и закрытие склада, часы доставки",
|
||||||
|
"«Первая половина — до» — граница между половинами дня",
|
||||||
|
"«Сегодня предлагаем до» — до какого времени предлагать сегодняшний день",
|
||||||
|
]},
|
||||||
|
{"title": "Как это работает в коде", "accent": ACCENT2, "items": [
|
||||||
|
"Окно звонка считается рабочими днями: выходные не сокращают окно, а пропускаются",
|
||||||
|
"Робот называет реальные дни голосом: «сегодня», «в понедельник», «во вторник»",
|
||||||
|
"Клиентская ссылка использует те же дни — расхождений между звонком и ссылкой нет",
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"full": {"title": "Текущие значения в системе", "accent": WARN, "items": [
|
||||||
|
"Склад: пн–пт, 09:00–17:30, половина дня до 13:00, «сегодня» предлагаем до 15:30",
|
||||||
|
"Доставка: пн–пт (суббота — выходной) 09:00–21:00, половина дня до 15:00",
|
||||||
|
"Утро и вечер: до 13:00 робот спросит, какая половина дня удобнее; после 13:00 — сразу зафиксирует вторую",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "tablecard",
|
||||||
|
"title": "Самовывоз: какие дни предлагает",
|
||||||
|
"sub": "Окно — «день готовности + два рабочих дня», выходные пропускаются",
|
||||||
|
"table": {
|
||||||
|
"rows": [
|
||||||
|
["Заказ готов", "Что предложит робот", "Почему так"],
|
||||||
|
["в понедельник", "пн, вт, ср", "три рабочих дня подряд"],
|
||||||
|
["в пятницу", "пт, пн, вт", "суббота и воскресенье — выходные склада"],
|
||||||
|
["в субботу", "пн, вт, ср", "сам выходной не предлагаем"],
|
||||||
|
["в пятницу после 15:30", "пн, вт", "«сегодня» больше не предлагаем (настройка)"],
|
||||||
|
["в понедельник после 13:00", "пн, вт, ср", "половину дня не спрашиваем — сразу вторая"],
|
||||||
|
["неделю назад", "звонка нет", "окно прошло — звонить не о чем"],
|
||||||
|
],
|
||||||
|
"widths": [200, 240, 428],
|
||||||
|
},
|
||||||
|
"full": {"title": "Две настройки, которые это двигают", "accent": ACCENT, "items": [
|
||||||
|
"«Сегодня предлагаем до» (сейчас 15:30) — позже этого времени сегодняшний день исчезает из звонка и из ссылки",
|
||||||
|
"«Первая половина — до» (сейчас 13:00) — после неё робот не спрашивает половину дня, сразу пишет вторую",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "tablecard",
|
||||||
|
"title": "Доставка: не в день готовности",
|
||||||
|
"sub": "Нужен запас времени, чтобы собрать маршрут — поэтому начинаем со следующего дня",
|
||||||
|
"table": {
|
||||||
|
"rows": [
|
||||||
|
["Заказ готов", "Что предложит робот"],
|
||||||
|
["в понедельник", "вторник и среда"],
|
||||||
|
["в пятницу", "понедельник и вторник (в субботу не возим)"],
|
||||||
|
["в субботу", "понедельник и вторник"],
|
||||||
|
["в четверг, звонок в пятницу", "пятница и понедельник"],
|
||||||
|
["в пятницу, звонок в понедельник", "понедельник и вторник"],
|
||||||
|
],
|
||||||
|
"widths": [300, 568],
|
||||||
|
},
|
||||||
|
"full": {"title": "Ключевое отличие от самовывоза", "accent": ACCENT2, "items": [
|
||||||
|
"В день готовности доставку не предлагаем никогда — только следующие рабочие дни",
|
||||||
|
"Суббота и воскресенье в графике доставки — выходные, их робот не назовёт",
|
||||||
|
"Время доставки — половина дня: до 15:00 или после (границу задаёт админка)",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "tablecard",
|
||||||
|
"title": "Как проходит разговор",
|
||||||
|
"sub": "Робот задаёт вопрос и ждёт цифру; молчание не считается согласием",
|
||||||
|
"table": {
|
||||||
|
"rows": [
|
||||||
|
["Шаг", "Что говорит робот", "Что делает клиент", "Сколько ждёт"],
|
||||||
|
["Приветствие", "«Ваш заказ готов. Выберите день и время доставки»", "слушает", "—"],
|
||||||
|
["День", "«Нажмите один, чтобы доставить. В понедельник. Нажмите два…»", "нажимает 1 или 2", "9 секунд"],
|
||||||
|
["Половина дня", "«Первая половина дня или вторая?»", "нажимает 1 или 2", "9 секунд"],
|
||||||
|
["Подтверждение", "«Первая половина дня» — озвучивает выбор", "слушает", "—"],
|
||||||
|
["Изменить?", "подсказка про кнопку 2 — один раз", "нажимает 2, чтобы переделать", "6 секунд"],
|
||||||
|
],
|
||||||
|
"widths": [130, 360, 200, 178],
|
||||||
|
},
|
||||||
|
"full": {"title": "Что делает при ошибке ввода", "accent": WARN, "items": [
|
||||||
|
"Не понял цифру — повторяет тот же вопрос один раз",
|
||||||
|
"Снова не понял или молчит — завершает: «Вы не выбрали время. С вами свяжется менеджер»",
|
||||||
|
"Клиент нажимает 2 — диалог начинается заново («день и время»), прежний слот при этом не теряется",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "tablecard",
|
||||||
|
"title": "Что можно нажать",
|
||||||
|
"sub": "Всего несколько цифр — и одна из них «больше не звоните»",
|
||||||
|
"table": {
|
||||||
|
"rows": [
|
||||||
|
["Цифра", "Значение", "Что происходит"],
|
||||||
|
["1", "первый предложенный день", "робот переходит к выбору половины дня"],
|
||||||
|
["2", "второй предложенный день", "робот переходит к выбору половины дня"],
|
||||||
|
["3", "третий день (только самовывоз)", "робот переходит к выбору половины дня"],
|
||||||
|
["1 / 2", "половина дня", "первая половина / вторая половина"],
|
||||||
|
["9", "отказ от звонков", "робот больше не позвонит; в карточке — «отказ от звонков»"],
|
||||||
|
["0 и прочие", "не принимаются", "вопрос повторяется; соединения с оператором нет"],
|
||||||
|
],
|
||||||
|
"widths": [110, 240, 518],
|
||||||
|
},
|
||||||
|
"full": {"title": "Отдельно про «0»", "accent": DANGER, "items": [
|
||||||
|
"Раньше в диалоге была попытка «позвать менеджера», но она никогда не срабатывала (нажатие не отличалось от тишины)",
|
||||||
|
"Эту ветку убрали: живого соединения с оператором у робота нет, менеджер подключается только задачей из системы",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "cards2full",
|
||||||
|
"title": "Если не дозвонились",
|
||||||
|
"sub": "Робот различает «не взял трубку» и «вместо человека ответил автоответчик/оператор»",
|
||||||
|
"cards": [
|
||||||
|
{"title": "Как понимает, что трубку не взяли", "accent": WARN, "items": [
|
||||||
|
"Никто не ответил за 45 секунд — попытка считается неудачной",
|
||||||
|
"Трубку подняли, но гудки шли больше 20 секунд и ни одной цифры не нажали — это объявление оператора, а не человек: пишем «не взял трубку»",
|
||||||
|
"Живой человек почти всегда берёт трубку раньше, поэтому правило надёжно отделяет автоответчик",
|
||||||
|
]},
|
||||||
|
{"title": "Что происходит дальше", "accent": ACCENT, "items": [
|
||||||
|
"Повторный звонок — не раньше чем через 120 минут (настройка)",
|
||||||
|
"Всего не больше 5 попыток на заказ (настройка)",
|
||||||
|
"Звонит только в рабочие часы кампании: 09:00–20:00, дни — по настройке",
|
||||||
|
"Исчерпали попытки — заказ уходит менеджеру, автозвонок по нему останавливается",
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"full": {"title": "Для менеджера это видно в карточке", "accent": ACCENT2, "items": [
|
||||||
|
"Каждая попытка — отдельная строка истории: время, результат, длительность разговора",
|
||||||
|
"Статус заказа меняется на «нужен менеджер» — с комментарием «не дозвонились / не нажал»",
|
||||||
|
"Отдельно красным предупреждаем, если заявка «Позвонить сейчас» висит больше 15 минут",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "tablecard",
|
||||||
|
"title": "Если сбросили трубку в середине разговора",
|
||||||
|
"sub": "Робот запоминает то, что успел услышать, и не портит уже согласованный слот",
|
||||||
|
"table": {
|
||||||
|
"rows": [
|
||||||
|
["Когда положили трубку", "Что запишет робот", "Что увидит менеджер"],
|
||||||
|
["до выбора дня", "ответил, ничего не нажал", "«взял, не нажал (день)» — попытка зачтена"],
|
||||||
|
["день выбрал, время не успел", "день выбран, время нет", "«взял, не нажал (время)»"],
|
||||||
|
["выбор подтвердил и сбросил", "слот согласован", "дата и половина дня в заказе, статус «согласовано»"],
|
||||||
|
["начал менять выбор и сбросил", "остаётся прежний слот", "«правка не доведена» — прежнее согласование в силе"],
|
||||||
|
],
|
||||||
|
"widths": [250, 250, 368],
|
||||||
|
},
|
||||||
|
"full": {"title": "Почему так", "accent": ACCENT2, "items": [
|
||||||
|
"Слот становится согласованным только после двух ответов: день и половина дня — это защита от «услышал половину фразы»",
|
||||||
|
"Обрыв не считается согласием: если клиент не подтвердил, робот перезвонит по расписанию или отдаст заказ менеджеру",
|
||||||
|
"Если клиент уже согласовал слот, а потом начал менять и бросил трубку — прежний выбор сохраняется, ничего не теряется",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "cards3full",
|
||||||
|
"title": "Когда подключается менеджер",
|
||||||
|
"sub": "Робот доводит дело до конца сам, но в трёх случаях передаёт человеку",
|
||||||
|
"cards": [
|
||||||
|
{"title": "Не нажал цифру 2 раза", "accent": WARN, "items": [
|
||||||
|
"Робот писал «взял, но не нажал» дважды",
|
||||||
|
"Заказ помечается «нужен менеджер», комментарий «не дозвонились / не нажал»",
|
||||||
|
"Порог «не нажал × N» настраивается в админке автодозвона",
|
||||||
|
]},
|
||||||
|
{"title": "Исчерпаны 5 попыток", "accent": WARN, "items": [
|
||||||
|
"Каждый звонок — отдельная попытка",
|
||||||
|
"Когда попытки закончились, автозвонок прекращается",
|
||||||
|
"Заказ остаётся в списке у менеджера",
|
||||||
|
]},
|
||||||
|
{"title": "Отказ или неверный номер", "accent": DANGER, "items": [
|
||||||
|
"Цифра 9 — клиент просит больше не звонить: ставим запрет автоматически",
|
||||||
|
"Неверный номер — задача менеджеру",
|
||||||
|
"Отказ клиента действует и на другие заказы: он в списке «не звонить»",
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"full": {"title": "Что видит менеджер в карточке заказа", "accent": ACCENT, "items": [
|
||||||
|
"Единый статус автодозвона и история всех попыток с результатами и слотами",
|
||||||
|
"Кнопка «Позвонить сейчас» — ручной звонок клиенту (не тестовый номер) и живое состояние: «в очереди», «звоним», «звонок сделан» с результатом",
|
||||||
|
"Кнопка «Не звонить» — если робот не должен беспокоить этого клиента",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "cards2full",
|
||||||
|
"title": "Когда робот не звонит вообще",
|
||||||
|
"sub": "Список условий, при которых звонка не будет — проверяются до набора номера",
|
||||||
|
"cards": [
|
||||||
|
{"title": "По клиенту и заказу", "accent": ACCENT, "items": [
|
||||||
|
"Клиент сам подтвердил день по ссылке — звонок не нужен",
|
||||||
|
"Клиент просил больше не звонить (нажал 9) или менеджер поставил «Не звонить»",
|
||||||
|
"Номер в списке исключений — например, на МТС-диапазон звонки не идут",
|
||||||
|
"Самовывоз: автообзвон по самовывозу включается отдельной галочкой (сейчас выключен)",
|
||||||
|
]},
|
||||||
|
{"title": "По времени и географии", "accent": WARN, "items": [
|
||||||
|
"Вне рабочих часов кампании (09:00–20:00) и вне рабочих дней",
|
||||||
|
"Город исключён целиком или для города выключен канал",
|
||||||
|
"В админке автодозвона выключены звонки по городу — например, Ялта: SMS идут, звонков нет",
|
||||||
|
"Меньше 90 минут прошло после SMS со ссылкой",
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"full": {"title": "Админка автодозвона: где это переключается", "accent": ACCENT2, "items": [
|
||||||
|
"Панель «Автодозвон»: тестовый режим, часы и дни звонков, паузы, лимит попыток, порог «не нажал × N»",
|
||||||
|
"Панель «Локации»: для каждого города — звонки включены или выключены (SMS не затрагивается)",
|
||||||
|
"Панель исключений: номера и префиксы, на которые не звоним, с причиной",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "tablecard",
|
||||||
|
"title": "Что записывается в заказ",
|
||||||
|
"sub": "Результат звонка виден там же, где всё остальное",
|
||||||
|
"table": {
|
||||||
|
"rows": [
|
||||||
|
["Поле", "Значение"],
|
||||||
|
["Дата", "конкретная дата, которую назвал робот (например, понедельник 21.09)"],
|
||||||
|
["Время", "«первая половина дня» или «вторая половина дня»"],
|
||||||
|
["Источник", "«согласовано роботом» — видно, что выбор сделал клиент в звонке"],
|
||||||
|
["Статус", "«согласовано» / «нужен менеджер» / «отказ от звонков»"],
|
||||||
|
["Комментарий", "«Согласовано роботом: завтра, первая половина дня»"],
|
||||||
|
],
|
||||||
|
"widths": [200, 668],
|
||||||
|
},
|
||||||
|
"full": {"title": "Зачем это нужно", "accent": ACCENT, "items": [
|
||||||
|
"Менеджер видит причину и источник решения, а не просто «дата стоит»",
|
||||||
|
"История звонков хранится отдельно: видно, сколько было попыток и чем они закончились",
|
||||||
|
"Дата, названная роботом, используется дальше в работе склада и доставки",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "tablecard",
|
||||||
|
"title": "Тестовый режим и текущее состояние",
|
||||||
|
"sub": "Сейчас обзвон клиентов выключен — система в тестовом режиме",
|
||||||
|
"table": {
|
||||||
|
"rows": [
|
||||||
|
["Параметр", "Значение сейчас", "Что будет при запуске"],
|
||||||
|
["Обзвон клиентов", "выключен", "включается отдельной командой"],
|
||||||
|
["Тестовый номер", "звонит только на него", "массовый обзвон идёт клиентам"],
|
||||||
|
["Кнопка «Позвонить сейчас»", "звонит клиенту сразу", "так же — звонок клиенту"],
|
||||||
|
["Звонки по самовывозу", "выключены галочкой", "включаются отдельно от доставки"],
|
||||||
|
],
|
||||||
|
"widths": [230, 280, 358],
|
||||||
|
},
|
||||||
|
"full": {"title": "Как проверяется перед запуском", "accent": ACCENT2, "items": [
|
||||||
|
"Тестовый звонок на свой номер: диалог, дни, половина дня, запись результата",
|
||||||
|
"Прогон диалога без звонка: проверка всех веток (согласие, обрыв, молчание, отказ)",
|
||||||
|
"Проверка окон: робот предлагает именно те дни, которые заданы в админке",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "cards2full",
|
||||||
|
"title": "Что пока не учитывается",
|
||||||
|
"sub": "Честный список: это важно знать до запуска",
|
||||||
|
"cards": [
|
||||||
|
{"title": "График", "accent": WARN, "items": [
|
||||||
|
"Праздники: календаря нерабочих дат нет — если праздник попадёт в окно, робот его предложит",
|
||||||
|
"Часы доставки используются частично: границу половины дня робот проговаривает словами, без конкретного времени",
|
||||||
|
]},
|
||||||
|
{"title": "Настройки, которые пока не влияют", "accent": DANGER, "items": [
|
||||||
|
"«Попыток в день» — поле есть, но расписание повторов задаётся интервалом между попытками",
|
||||||
|
"«Попытки следующего дня (10:00, 14:00)» — пока не используются",
|
||||||
|
"«Таймаут звонка, 30 секунд» — звонок ждёт ответа 45 секунд (значение в самом звонке)",
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"full": {"title": "Что предлагаем уточнить до боевого запуска", "accent": ACCENT2, "items": [
|
||||||
|
"Список праздников (или решение не учитывать их)",
|
||||||
|
"Нужен ли жёсткий график повторов по времени, кроме интервала 120 минут",
|
||||||
|
"Оставить ли «0» без реакции — сейчас это просто неверная цифра",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "cards2full",
|
||||||
|
"title": "Коротко",
|
||||||
|
"sub": "Одна страница вместо всей презентации",
|
||||||
|
"cards": [
|
||||||
|
{"title": "Что делает система", "accent": ACCENT, "full": True, "dense": True, "items": [
|
||||||
|
"Ждёт 90 минут после SMS и звонит, если клиент не подтвердил день сам",
|
||||||
|
"Предлагает только рабочие дни по графику: самовывоз — включая сегодня, доставка — со следующего дня",
|
||||||
|
"Спрашивает день и половину дня, подтверждает вслух, даёт один раз переделать выбор",
|
||||||
|
"Повторяет попытки не чаще чем раз в 2 часа, максимум 5 раз, только 09:00–20:00 в рабочие дни",
|
||||||
|
"После двух «взял, но не нажал» или исчерпания попыток передаёт заказ менеджеру",
|
||||||
|
"Отказ (цифра 9) останавливает обзвон клиента навсегда",
|
||||||
|
"Каждая попытка и результат видны в карточке заказа; ручной звонок идёт клиенту и показывает своё состояние",
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"full": {"title": "Что осталось решить", "accent": WARN, "items": [
|
||||||
|
"Праздники и точный график повторов — по вашим правилам",
|
||||||
|
"Момент включения боевого обзвона клиентов и по самовывозу",
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL_SLIDES = [TITLE_SLIDE] + SLIDES
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HTML-версия презентации «Автодозвон: как это работает» (тот же текст, что в PDF).
|
||||||
|
|
||||||
|
Содержание берётся из deck_content.py, поэтому PDF и HTML не разъезжаются.
|
||||||
|
Файл самодостаточный: без внешних шрифтов, картинок и скриптов — открывается
|
||||||
|
двойным щелчком и с телефона, и печатается в PDF по одному слайду на страницу.
|
||||||
|
|
||||||
|
Запуск: python3 docs/autocall/render-html.py
|
||||||
|
"""
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import deck_content as content
|
||||||
|
|
||||||
|
OUT = Path(__file__).with_name("autocall-how-it-works.html")
|
||||||
|
|
||||||
|
TONE = {
|
||||||
|
"accent": "#4ADE80",
|
||||||
|
"accent2": "#60A5FA",
|
||||||
|
"warn": "#FBBF24",
|
||||||
|
"danger": "#F87171",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def esc(text):
|
||||||
|
return escape(str(text))
|
||||||
|
|
||||||
|
|
||||||
|
def card_html(card, extra_class=""):
|
||||||
|
tone = TONE.get(card.get("accent", "accent"), TONE["accent"])
|
||||||
|
items = "".join(f"<li>{esc(i)}</li>" for i in card["items"])
|
||||||
|
classes = " ".join(f"card {extra_class} {'dense' if card.get('dense') else ''}".split())
|
||||||
|
return (
|
||||||
|
f'<section class="{classes}" style="--tone:{tone}">'
|
||||||
|
f'<h3>{esc(card["title"])}</h3><ul>{items}</ul></section>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def table_html(table):
|
||||||
|
head, *body = table["rows"]
|
||||||
|
widths = table.get("widths") or []
|
||||||
|
colgroup = "".join(f'<col style="width:{w / sum(widths) * 100:.2f}%">' for w in widths) if widths else ""
|
||||||
|
head_html = "".join(f"<th>{esc(c)}</th>" for c in head)
|
||||||
|
body_html = "".join(
|
||||||
|
"<tr>" + "".join(f"<td>{esc(c)}</td>" for c in row) + "</tr>" for row in body
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'<table>{"<colgroup>" + colgroup + "</colgroup>" if colgroup else ""}'
|
||||||
|
f"<thead><tr>{head_html}</tr></thead><tbody>{body_html}</tbody></table>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def steps_html(steps):
|
||||||
|
out = []
|
||||||
|
for i, (title, desc) in enumerate(steps):
|
||||||
|
lines = "".join(f"<span>{esc(line)}</span>" for line in desc.split("\n"))
|
||||||
|
arrow = '<div class="arrow">›</div>' if i < len(steps) - 1 else ""
|
||||||
|
out.append(f'<div class="step"><b>{esc(title)}</b>{lines}{arrow}</div>')
|
||||||
|
return f'<div class="steps">{"".join(out)}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def slide_html(slide, number):
|
||||||
|
kind = slide["kind"]
|
||||||
|
parts = ['<footer><span>Автодозвон SuperSam · как это работает</span>'
|
||||||
|
f'<span>{number}</span></footer>']
|
||||||
|
|
||||||
|
if kind == "title":
|
||||||
|
lines = "".join(f"<p>{esc(line)}</p>" if line else "<p> </p>" for line in slide["lines"])
|
||||||
|
body = (
|
||||||
|
f'<div class="kicker">{esc(slide["kicker"])}</div>'
|
||||||
|
f'<h1>{esc(slide["title"])}</h1>'
|
||||||
|
f'<p class="sub">{esc(slide["sub"])}</p>'
|
||||||
|
f'<div class="card titlecard" style="--tone:{TONE["accent"]}">{lines}</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
head = f'<h2>{esc(slide["title"])}</h2><p class="sub">{esc(slide["sub"])}</p>'
|
||||||
|
body = head
|
||||||
|
if kind == "steps":
|
||||||
|
body += steps_html(slide["steps"])
|
||||||
|
elif kind in ("cards2full", "cards3full"):
|
||||||
|
cards = "".join(card_html(c) for c in slide["cards"])
|
||||||
|
body += f'<div class="grid {kind}">{cards}</div>'
|
||||||
|
elif kind == "tablecard":
|
||||||
|
body += table_html(slide["table"])
|
||||||
|
full = slide.get("full")
|
||||||
|
if full:
|
||||||
|
body += card_html(full, extra_class="full")
|
||||||
|
|
||||||
|
return f'<section class="slide {kind}">{body}{parts[0]}</section>'
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
slides = "".join(slide_html(s, i + 1) for i, s in enumerate(content.ALL_SLIDES))
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Автодозвон SuperSam — как это работает</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #0B1220; --bg2: #101A2C; --card: #131E33; --border: #25344F;
|
||||||
|
--text: #E8EEF9; --dim: #9FB0CC; --accent: {TONE['accent']};
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0; background: var(--bg); color: var(--text);
|
||||||
|
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 24px; padding: 24px 12px 48px;
|
||||||
|
}}
|
||||||
|
.slide {{
|
||||||
|
position: relative; width: min(1180px, 100%); aspect-ratio: 16 / 9; overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, var(--bg2) 0%, var(--bg) 34%);
|
||||||
|
border: 1px solid var(--border); border-radius: 18px;
|
||||||
|
padding: 36px 40px 46px; display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}}
|
||||||
|
.kicker {{ color: var(--accent); font-weight: 700; letter-spacing: .08em; font-size: 13px; text-transform: uppercase; }}
|
||||||
|
h1 {{ font-size: 40px; line-height: 1.15; margin: 6px 0 0; }}
|
||||||
|
h2 {{ font-size: 26px; margin: 0; }}
|
||||||
|
h3 {{ color: var(--tone, var(--accent)); font-size: 15px; margin: 0 0 8px; }}
|
||||||
|
.sub {{ color: var(--dim); margin: 4px 0 6px; font-size: 15px; }}
|
||||||
|
.grid {{ display: grid; gap: 14px; }}
|
||||||
|
.grid.cards2full {{ grid-template-columns: 1fr 1fr; }}
|
||||||
|
.grid.cards3full {{ grid-template-columns: repeat(3, 1fr); }}
|
||||||
|
.card {{
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--tone, var(--accent));
|
||||||
|
border-radius: 12px; padding: 14px 16px;
|
||||||
|
}}
|
||||||
|
.card.full {{ grid-column: 1 / -1; }}
|
||||||
|
.card ul {{ margin: 0; padding-left: 18px; }}
|
||||||
|
.card li {{ margin: 5px 0; font-size: 14.5px; color: var(--text); }}
|
||||||
|
.card li::marker {{ color: var(--tone, var(--accent)); }}
|
||||||
|
.card.dense ul {{ column-count: 2; column-gap: 28px; }}
|
||||||
|
.titlecard p {{ margin: 6px 0; color: var(--text); }}
|
||||||
|
.steps {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }}
|
||||||
|
.step {{
|
||||||
|
position: relative; background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}}
|
||||||
|
.step b {{ color: var(--accent); font-size: 13.5px; }}
|
||||||
|
.step span {{ color: var(--text); font-size: 13px; }}
|
||||||
|
.arrow {{ position: absolute; right: -11px; top: 50%; transform: translateY(-50%); color: var(--dim); font-size: 20px; }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }}
|
||||||
|
thead th {{ background: #182640; color: var(--text); text-align: left; font-size: 13.5px; }}
|
||||||
|
th, td {{ padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 13.5px; vertical-align: top; }}
|
||||||
|
tbody tr:nth-child(even) td {{ background: rgba(255,255,255,.015); }}
|
||||||
|
th:first-child, td:first-child {{ color: var(--dim); }}
|
||||||
|
footer {{
|
||||||
|
position: absolute; left: 40px; right: 40px; bottom: 14px; display: flex; justify-content: space-between;
|
||||||
|
color: var(--dim); font-size: 11.5px; border-top: 1px solid var(--border); padding-top: 8px;
|
||||||
|
}}
|
||||||
|
@media (max-width: 1200px) {{
|
||||||
|
/* Узкое окно: фиксированная 16:9 начинает резать текст — даём слайду расти */
|
||||||
|
.slide {{ aspect-ratio: auto; }}
|
||||||
|
}}
|
||||||
|
@media (max-width: 900px) {{
|
||||||
|
.grid.cards2full, .grid.cards3full, .steps {{ grid-template-columns: 1fr; }}
|
||||||
|
.card.dense ul {{ column-count: 1; }}
|
||||||
|
.arrow {{ display: none; }}
|
||||||
|
h2 {{ font-size: 22px; }}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{ padding: 12px 8px 32px; }}
|
||||||
|
.slide {{ aspect-ratio: auto; padding: 22px 18px 52px; }}
|
||||||
|
h1 {{ font-size: 26px; }} h2 {{ font-size: 20px; }}
|
||||||
|
.grid.cards2full, .grid.cards3full, .steps {{ grid-template-columns: 1fr; }}
|
||||||
|
.card.dense ul {{ column-count: 1; }}
|
||||||
|
.arrow {{ display: none; }}
|
||||||
|
th, td {{ font-size: 12px; padding: 7px 8px; }}
|
||||||
|
.card li {{ font-size: 13.5px; }}
|
||||||
|
}}
|
||||||
|
@media print {{
|
||||||
|
body {{ background: #fff; padding: 0; gap: 0; }}
|
||||||
|
.slide {{ width: 100%; height: 100vh; border-radius: 0; border: 0; page-break-after: always; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{slides}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
OUT.write_text(html, encoding="utf-8")
|
||||||
|
print(f"готово: {OUT} — слайдов {len(content.ALL_SLIDES)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build()
|
||||||
Loading…
Reference in New Issue