162 lines
7.7 KiB
Python
162 lines
7.7 KiB
Python
"""Генератор письма с саммари встречи: plaintext + HTML.
|
||
|
||
HTML-версия — по мотивам макета `design/mockups/email-summary.html`
|
||
(упрощённая структура: шапка/участники/тело саммари/футер, цвета и типографика
|
||
светлой темы `design/DESIGN_SYSTEM.md`; почтовые клиенты игнорируют внешние
|
||
`<style>`, поэтому все стили — инлайн). Тело саммари приходит от LLM
|
||
(`conference_sessions.summary_data`) в фиксированном markdown-подобном
|
||
формате промпта `workers/summarizer/prompts/summary_reduce_ru.txt` (заголовки
|
||
`## ...`, пункты `- ...`) — при рендере разбирается построчно и оборачивается
|
||
в HTML-разметку; заголовок конференции, имена участников и сам текст саммари
|
||
экранируются `html.escape`.
|
||
Plaintext-альтернатива — обязательный минимум для клиентов без HTML.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
from collections.abc import Sequence
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SummaryEmailContext:
|
||
"""Данные для рендера письма — без привязки к ORM (проще тестировать)."""
|
||
|
||
conference_title: str
|
||
date_label: str
|
||
"""Дата встречи в `display_timezone`, формат `ДД.ММ.ГГГГ`."""
|
||
time_label: str
|
||
"""Время встречи в `display_timezone`, формат `ЧЧ:ММ–ЧЧ:ММ`."""
|
||
duration_minutes: int
|
||
participant_names: Sequence[str]
|
||
summary_text: str
|
||
|
||
|
||
def build_summary_email(context: SummaryEmailContext) -> tuple[str, str]:
|
||
"""Собрать (plaintext, html) тело письма с саммари встречи."""
|
||
return _build_plaintext(context), _build_html(context)
|
||
|
||
|
||
def _build_plaintext(context: SummaryEmailContext) -> str:
|
||
"""Простой текстовый вариант — без экранирования (не HTML)."""
|
||
participants = ", ".join(context.participant_names) or "участники не определены"
|
||
lines = [
|
||
f"Саммари встречи «{context.conference_title}»",
|
||
f"{context.date_label}, {context.time_label} ({context.duration_minutes} мин)",
|
||
"",
|
||
f"Участники: {participants}",
|
||
"",
|
||
context.summary_text.strip(),
|
||
"",
|
||
"—",
|
||
"Письмо сформировано автоматически по итогам конференции в VidConf.",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _build_html(context: SummaryEmailContext) -> str:
|
||
"""HTML-вариант письма — все пользовательские подстановки экранированы."""
|
||
title = html.escape(context.conference_title)
|
||
date_label = html.escape(context.date_label)
|
||
time_label = html.escape(context.time_label)
|
||
participants = html.escape(", ".join(context.participant_names) or "не определены")
|
||
body_html = _render_summary_body(context.summary_text)
|
||
|
||
return f"""\
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
|
||
<body style="margin:0; padding:0; background-color:#F1F1F1; font-family:Helvetica, Arial, sans-serif;">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F1F1F1;">
|
||
<tr><td align="center" style="padding: 32px 16px;">
|
||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:600px; background-color:#FFFFFF; border-radius:24px; overflow:hidden; border:1px solid #E1E3E9;">
|
||
<tr>
|
||
<td style="padding: 32px 32px 24px 32px; background-color:#F7F7F8;">
|
||
<p style="margin: 0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#6976AC;">
|
||
Саммари встречи
|
||
</p>
|
||
<h1 style="margin:0 0 12px; font-family:Helvetica, Arial, sans-serif; font-size:24px; line-height:1.25; font-weight:bold; color:#2E3454;">
|
||
{title}
|
||
</h1>
|
||
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#535F94;">
|
||
{date_label}, {time_label} · {context.duration_minutes} мин
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 24px 32px 8px 32px;">
|
||
<p style="margin:0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#A6AECB;">
|
||
Участники
|
||
</p>
|
||
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#2E3454;">
|
||
{participants}
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
<tr><td style="padding: 16px 32px;"><hr style="border:none; border-top:1px solid #E1E3E9; margin:0;"></td></tr>
|
||
<tr>
|
||
<td style="padding: 8px 32px 24px 32px;">
|
||
{body_html}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 20px 32px 32px 32px; background-color:#F7F7F8; border-top:1px solid #E1E3E9;">
|
||
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:12px; line-height:1.6; color:#A6AECB;">
|
||
Письмо сформировано автоматически по итогам конференции в VidConf (self-hosted).
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def _render_summary_body(summary_text: str) -> str:
|
||
"""Разобрать markdown-подобный текст саммари (`## заголовок`, `- пункт`) в HTML.
|
||
|
||
Формат фиксирован промптом суммаризации; при
|
||
отклонении LLM от формата непонятые строки рендерятся как обычные
|
||
абзацы — разбор не должен падать на неожиданном вводе. Всё содержимое
|
||
экранируется `html.escape` (текст саммари — от LLM, потенциальная
|
||
инъекция в HTML-письмо).
|
||
"""
|
||
parts: list[str] = []
|
||
in_list = False
|
||
for raw_line in summary_text.strip("\n").splitlines():
|
||
line = raw_line.strip()
|
||
if not line:
|
||
continue
|
||
if line.startswith("## "):
|
||
if in_list:
|
||
parts.append("</ul>")
|
||
in_list = False
|
||
heading = html.escape(line[3:].strip())
|
||
parts.append(
|
||
'<p style="margin:16px 0 8px; font-family:Helvetica, Arial, sans-serif; '
|
||
'font-size:15px; font-weight:bold; color:#2E3454;">' + heading + "</p>"
|
||
)
|
||
elif line.startswith("- "):
|
||
if not in_list:
|
||
parts.append('<ul style="margin:0 0 8px; padding-left:20px;">')
|
||
in_list = True
|
||
item = html.escape(line[2:].strip())
|
||
parts.append(
|
||
'<li style="font-family:Helvetica, Arial, sans-serif; font-size:14px; '
|
||
'line-height:1.6; color:#2E3454; padding:2px 0;">' + item + "</li>"
|
||
)
|
||
else:
|
||
if in_list:
|
||
parts.append("</ul>")
|
||
in_list = False
|
||
parts.append(
|
||
'<p style="margin:0 0 8px; font-family:Helvetica, Arial, sans-serif; '
|
||
'font-size:14px; line-height:1.6; color:#2E3454;">' + html.escape(line) + "</p>"
|
||
)
|
||
if in_list:
|
||
parts.append("</ul>")
|
||
return "\n ".join(parts)
|