Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
"""Генератор письма с саммари встречи: 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} &middot; {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)