gendesign/backend/app/services/exporters/report_md.py
Light1YT 579ad54202
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
feat(export): compact Telegram summary export for §22 forecast (#959)
render_report_telegram_summary (pure, no new deps, DRY-reuses report_pdf str
helpers) + `tg` format on GET /{cad}/forecast/export → inline text/plain snippet
(no attachment, copy-paste-ready). md/json unchanged; no-run 404, bad format 422.
Graceful on thin/empty reports. Part of EPIC #959.
2026-06-06 18:55:47 +00:00

527 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§13/§22 Markdown-экспортёр итогового советующего отчёта Site Finder v2.
#959 (EPIC export). Самый дешёвый, БЕЗ-runtime-dep двойник PDF/Excel-экспортёров
(`report_pdf` / `excel`): рендерит `SiteFinderReport` (#987) в ЧИТАЕМЫЙ Markdown-текст
и возвращает СТРОКУ — готовую для `Response(media_type="text/markdown")` / записи.
Чистый str-билдинг (НИКАКИХ новых зависимостей: ни python-docx, ни pptx, ни шаблонизатора).
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
`_fmt` — строковое форматирование ячейки/None→"", `_level_ru`, `_as_dict`/`_as_list`,
`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). `report_pdf._fmt` возвращает `str` (в отличие
от `excel._fmt`, отдающего `Any` под ячейку openpyxl) — для Markdown нужен именно str,
поэтому источник хелперов — `report_pdf`. Здесь добавлены ТОЛЬКО Markdown-специфичные
микро-билдеры (kv-строки / таблицы / экранирование pipe).
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его
наполняет сборщик #988). Принимает КАК `SiteFinderReport`-инстанс, ТАК и его
`as_dict()`-словарь (нормализуется через `_normalize`).
GRACEFUL (дух всего форсайт-стека): частичный/пустой/мусорный отчёт ВАЛИДЕН — пустая
секция рисует «нет данных», экспортёр НИКОГДА не падает (нет KeyError на тонком отчёте).
Отчёт СОВЕТУЮЩИЙ: после H1 — заметный advisory-дисклеймер (оценка не основание для
инвест-решения). Содержание и ПОРЯДОК секций ЗЕРКАЛЯТ `report_pdf`.
"""
from __future__ import annotations
import logging
from typing import Any
from app.services.exporters.report_pdf import (
_ADVISORY_MARKER,
_DASH,
_NO_DATA,
_as_dict,
_as_list,
_fmt,
_future_supply_pairs,
_level_ru,
_normalize,
_scenario_deficit_index,
)
logger = logging.getLogger(__name__)
# ── Named-константы: заголовки (зеркало report_pdf — те же восемь секций §13) ──
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт"
_TITLE_SUMMARY: str = "Сводка"
_TITLE_MARKET_NOW: str = "Рынок сейчас"
_TITLE_FUTURE_MARKET: str = "Будущий рынок"
_TITLE_SCENARIOS: str = "Сценарии"
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
_TITLE_SCORING: str = "Скоринг"
_TITLE_CONFIDENCE: str = "Уверенность"
# Краткий RU-дисклеймер (формулировка из ТЗ #959) — после H1, виден сразу.
_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения."
# Telegram-сводка (#959): сверхкраткий caveat в финальной строке (≤ одной строки чата).
_TG_ADVISORY_CAVEAT: str = "Оценка advisory, не основание для инвест-решения."
# Заголовок-бренд Telegram-сводки (1-я строка). Plain-text, без Markdown.
_TG_TITLE: str = "Site Finder v2 — сводка §22"
# Канонический порядок сценариев в однострочной сводке дефицита (зеркало report_assembler).
_TG_SCENARIO_ORDER: tuple[str, ...] = ("base", "aggressive", "conservative")
# RU-метки сценариев для компактной строки «дефицит по сценариям».
_TG_SCENARIO_RU: dict[str, str] = {
"base": "база",
"aggressive": "агр",
"conservative": "конс",
}
# Сколько конкурентов выводить в таблицу (top-N, чтобы не раздувать MD). Зеркало
# competitors_limit=5 у snapshot-экспорта.
_COMPETITORS_TOP_N: int = 5
# Основной продуктовый горизонт (мес) для подписи сводного дефицита сценария
# (зеркало report_pdf._PRIMARY_HORIZON_MONTHS — там private, держим локальный дубль
# ТОЛЬКО как подпись столбца, само значение тянет импортируемый _scenario_deficit_index).
_PRIMARY_HORIZON_MONTHS: int = 12
# ──────────────────────────────────────────────────────────────────────────────
# Markdown-специфичные микро-билдеры. Все PURE: строят строку из уже-форматированных
# через `_fmt` кусочков. (Хелперы нормализации/форматирования — импортированы из
# report_pdf, не дублируются — DRY.)
# ──────────────────────────────────────────────────────────────────────────────
def _md_cell(value: Any) -> str:
"""Значение → текст ячейки Markdown-таблицы: `_fmt` + экранирование `|`. PURE.
Pipe внутри значения сломал бы разметку таблицы — заменяем на escaped `\\|`.
Переводы строк сворачиваем в пробел (ячейка таблицы однострочна).
"""
text = _fmt(value)
return text.replace("|", "\\|").replace("\n", " ").replace("\r", " ")
def _md_table(headers: list[str], rows: list[list[Any]]) -> str:
"""GFM-таблица: шапка + разделитель + строки (через `_md_cell`). PURE.
Пустой `rows` → строка-заглушка «нет данных» (graceful — таблица валидна).
Возвращает блок без хвостовых пустых строк (склейку делает вызывающий).
"""
head = "| " + " | ".join(headers) + " |"
sep = "| " + " | ".join("---" for _ in headers) + " |"
if not rows:
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
return "\n".join([head, sep, empty])
body = [
"| " + " | ".join(_md_cell(cell) for cell in row) + " |"
for row in rows
]
return "\n".join([head, sep, *body])
def _md_kv_table(data: dict[str, Any]) -> str:
"""Таблица «Показатель → Значение» из плоского dict. Пустой → «нет данных». PURE."""
rows = [[str(key), value] for key, value in data.items()]
return _md_table(["Показатель", "Значение"], rows)
def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str:
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE."""
if not pairs:
return _NO_DATA
return "\n".join(f"- **{label}:** {_fmt(value)}" for label, value in pairs)
def _join_horizons(values: list[Any]) -> Any:
"""Свернуть список горизонтов в «6, 12, 18» или None (→ `_fmt` отдаст ""). PURE."""
return ", ".join(str(v) for v in values) if values else None
# ──────────────────────────────────────────────────────────────────────────────
# Построители секций — по одной на содержательную секцию §13, тот же порядок/набор,
# что и в report_pdf. Каждый graceful (пустая секция → «нет данных», не падает) и
# возвращает Markdown-блок (строку) без ведущего/хвостового перевода строки.
# ──────────────────────────────────────────────────────────────────────────────
def _build_header(report: dict[str, Any]) -> str:
"""H1 + advisory-дисклеймер + карточка meta (cad/район/горизонты/дата/схема). PURE."""
meta = _as_dict(report.get("meta"))
schema_version = report.get("schema_version") or meta.get("schema_version")
meta_pairs: list[tuple[str, Any]] = [
("Кадастровый номер", meta.get("cad_num")),
("Район", meta.get("district")),
("Горизонты (мес)", _join_horizons(_as_list(meta.get("horizons")))),
("Сформировано", meta.get("generated_at")),
("Версия схемы", schema_version),
]
return (
f"# {_TITLE_DOC}\n\n"
f"> **{_ADVISORY_DISCLAIMER}** {_ADVISORY_MARKER}\n\n"
f"{_md_kv_lines(meta_pairs)}"
)
def _build_summary(report: dict[str, Any]) -> str:
"""§13.1 «Сводка»: headline (bold) + вердикт + общая уверенность + ключевые числа."""
exec_summary = _as_dict(report.get("exec_summary"))
headline = exec_summary.get("headline")
verdict = exec_summary.get("verdict")
key_numbers = _as_dict(exec_summary.get("key_numbers"))
overall_conf = _level_ru(exec_summary.get("overall_confidence"))
return (
f"## {_TITLE_SUMMARY}\n\n"
f"**{_fmt(headline)}**\n\n"
f"{_fmt(verdict)}\n\n"
f"_Общая уверенность: {overall_conf}_\n\n"
f"### Ключевые числа\n\n"
f"{_md_kv_table(key_numbers)}"
)
def _build_market_now(report: dict[str, Any]) -> str:
"""§13.2 «Рынок сейчас»: резюме + метрики + слои предложения + конкуренты (top-N)."""
market_now = _as_dict(report.get("market_now"))
metrics = _as_dict(market_now.get("market_metrics"))
supply = _as_dict(market_now.get("supply_layers"))
competitors = _as_list(market_now.get("competitors"))
comp_rows = [
[c.get("comm_name"), c.get("obj_id"), c.get("relevance_weight")]
for c in competitors[:_COMPETITORS_TOP_N]
if isinstance(c, dict)
]
return (
f"## {_TITLE_MARKET_NOW}\n\n"
f"{_fmt(market_now.get('summary'))}\n\n"
f"### Метрики рынка\n\n"
f"{_md_kv_table(metrics)}\n\n"
f"### Слои предложения\n\n"
f"{_md_kv_table(supply)}\n\n"
f"### Конкуренты\n\n"
f"{_md_table(['ЖК', 'ID', 'Релевантность'], comp_rows)}"
)
def _build_future_market(report: dict[str, Any]) -> str:
"""§13.3 «Будущий рынок»: таблица по горизонтам + давление предложения + сценарии."""
future = _as_dict(report.get("future_market"))
forecasts = _as_list(future.get("forecasts_by_horizon"))
scenarios_summary = _as_dict(future.get("scenarios_summary"))
forecast_headers = [
"Горизонт, мес",
"Индекс дефицита",
"Месяцев запаса",
"Спрос",
"Предложение",
"Ставка",
"Уверенность",
]
forecast_rows = [
[
f.get("horizon_months"),
f.get("deficit_index"),
f.get("months_of_inventory"),
f.get("projected_demand_units"),
f.get("projected_supply_units"),
f.get("rate_future"),
_level_ru(f.get("confidence")),
]
for f in forecasts
if isinstance(f, dict)
]
return (
f"## {_TITLE_FUTURE_MARKET}\n\n"
f"{_fmt(future.get('summary'))}\n\n"
f"### Прогноз по горизонтам\n\n"
f"{_md_table(forecast_headers, forecast_rows)}\n\n"
f"### Давление будущего предложения\n\n"
f"{_md_kv_table(_future_supply_pairs(future.get('future_supply')))}\n\n"
f"### Разброс сценариев (индекс дефицита)\n\n"
f"{_md_kv_table(scenarios_summary)}"
)
def _build_scenarios(report: dict[str, Any]) -> str:
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
scenarios = _as_dict(report.get("scenarios"))
by_scenario = _as_dict(scenarios.get("by_scenario"))
rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
rate_path = _as_dict(data.get("rate_path"))
rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
)
rows.append(
[name, _scenario_deficit_index(data), rate_str, data.get("advisory")]
)
headers = [
"Сценарий",
f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)",
"Траектория ставки",
"Advisory",
]
return (
f"## {_TITLE_SCENARIOS}\n\n"
f"{_fmt(scenarios.get('summary'))}\n\n"
f"{_md_table(headers, rows)}"
)
def _build_product_tz(report: dict[str, Any]) -> str:
"""§13.4 «Продукт ТЗ»: класс + квартирография (mix) + коммерция + USP + причины."""
product = _as_dict(report.get("product_tz"))
mix = _as_list(product.get("mix"))
commercial = _as_dict(product.get("commercial"))
usp = _as_list(product.get("usp"))
reasons = _as_list(product.get("reasons"))
head_pairs: list[tuple[str, Any]] = [
("Резюме", product.get("summary")),
("Рекомендованный класс", product.get("obj_class")),
]
mix_rows = [
[m.get("bucket"), m.get("pct"), m.get("obj_class"), m.get("deficit_index")]
for m in mix
if isinstance(m, dict)
]
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
reason_rows = [
[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)
]
return (
f"## {_TITLE_PRODUCT_TZ}\n\n"
f"{_md_kv_lines(head_pairs)}\n\n"
f"### Квартирография (mix)\n\n"
f"{_md_table(['Формат', 'Доля, %', 'Класс', 'Индекс дефицита'], mix_rows)}\n\n"
f"### Коммерция\n\n"
f"{_md_kv_table(commercial)}\n\n"
f"### USP-ниши\n\n"
f"{_md_table(['Сегмент', 'USP'], usp_rows)}\n\n"
f"### Причины (§16)\n\n"
f"{_md_table(['Почему', 'Advisory'], reason_rows)}"
)
def _build_scoring(report: dict[str, Any]) -> str:
"""§13.6 «Скоринг»: продуктовые скоры (#985) + спец-индексы (#986) + overall."""
scoring = _as_dict(report.get("scoring"))
product_scores = _as_dict(scoring.get("product_scores"))
special_indices = _as_dict(scoring.get("special_indices"))
scores = _as_dict(product_scores.get("scores"))
score_rows = [[name, _as_dict(payload).get("value")] for name, payload in scores.items()]
indices = _as_dict(special_indices.get("indices"))
index_rows = [
[name, _as_dict(payload).get("value"), _as_dict(payload).get("label")]
for name, payload in indices.items()
]
return (
f"## {_TITLE_SCORING}\n\n"
f"_Итоговый скор (overall): {_fmt(scoring.get('overall'))}_\n\n"
f"### Продуктовые скоры\n\n"
f"{_md_table(['Скор', 'Значение'], score_rows)}\n\n"
f"### Специальные индексы\n\n"
f"{_md_table(['Индекс', 'Значение', 'Метка'], index_rows)}"
)
def _build_confidence(report: dict[str, Any]) -> str:
"""§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица)."""
confidence = _as_dict(report.get("confidence"))
level = _level_ru(confidence.get("level"))
rationale = confidence.get("rationale")
factors = _as_dict(confidence.get("factors"))
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
# если значение — dict, раскладываем на value/level/note; иначе кладём как есть.
factor_rows: list[list[Any]] = []
for name, payload in factors.items():
if isinstance(payload, dict):
factor_rows.append(
[name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
)
else:
factor_rows.append([name, payload, _DASH, _DASH])
return (
f"## {_TITLE_CONFIDENCE}\n\n"
f"_Уровень: {level}_\n\n"
f"{_fmt(rationale)}\n\n"
f"### Факторы уверенности\n\n"
f"{_md_table(['Фактор', 'Значение', 'Уровень', 'Комментарий'], factor_rows)}"
)
# Реестр построителей. Порядок = порядок блоков в документе (зеркало report_pdf:
# Сводка → Рынок сейчас → Будущий рынок → Сценарии → Продукт ТЗ → Скоринг →
# Уверенность; header идёт отдельно первым).
_SECTION_BUILDERS: tuple[Any, ...] = (
_build_summary,
_build_market_now,
_build_future_market,
_build_scenarios,
_build_product_tz,
_build_scoring,
_build_confidence,
)
# ──────────────────────────────────────────────────────────────────────────────
# Публичный API — рендер отчёта в Markdown-строку (без файлового I/O, без зависимостей).
# ──────────────────────────────────────────────────────────────────────────────
def render_report_markdown(report: Any) -> str:
"""§13/§22 Отрендерить `SiteFinderReport` (#987) в читаемый Markdown и вернуть str.
H1 + advisory-дисклеймер + карточка meta, далее восемь содержательных секций §13
(Сводка / Рынок сейчас / Будущий рынок / Сценарии / Продукт ТЗ / Скоринг /
Уверенность) — содержание и порядок ЗЕРКАЛЯТ `report_pdf`. Числа форматируются
через импортированный `_fmt` (None → ""), уровни уверенности — RU-метками.
Чистый Markdown (GFM-таблицы), без HTML.
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети, БЕЗ новых зависимостей. Принимает КАК
`SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь (нормализуется через
импортированный `_normalize`). GRACEFUL: частичный/пустой/мусорный отчёт → секции
с «нет данных», НИКОГДА не падает (нет KeyError на тонком отчёте).
Args:
report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → "").
Returns:
Непустую Markdown-строку, готовую для
`Response(media_type="text/markdown; charset=utf-8")`.
"""
data = _normalize(report)
blocks: list[str] = [_build_header(data)]
blocks.extend(builder(data) for builder in _SECTION_BUILDERS)
markdown = "\n\n".join(blocks) + "\n"
meta = _as_dict(data.get("meta"))
logger.info(
"render_report_markdown: cad_num=%s sections=%d len=%d advisory=%s",
meta.get("cad_num"),
len(_SECTION_BUILDERS),
len(markdown),
data.get("advisory"),
)
return markdown
# ──────────────────────────────────────────────────────────────────────────────
# §22 Telegram-сводка — СВЕРХКРАТКИЙ копипаст-сниппет для чата (#959, EPIC export).
# Не файл-download, а текст-в-чат: длинный MD-отчёт не годится для Telegram, нужна
# выжимка ~8-15 строк. PURE, без новых зависимостей (только str), graceful, тот же
# вход/нормализация, что у render_report_markdown. DRY: переиспользует импортированные
# из report_pdf хелперы (`_normalize`/`_fmt`/`_level_ru`/`_as_dict`/`_as_list`/
# `_scenario_deficit_index`) — НЕ дублирует их. Telegram-safe: plain text + «•»-буллеты,
# без тяжёлых Markdown-таблиц (MarkdownV2 капризен) — копипаст-готово как есть.
# ──────────────────────────────────────────────────────────────────────────────
def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
"""Однострочная сводка дефицита по сценариям: «база -1.0 · агр -1.0 · конс -1.0».
PURE, graceful. Берёт `scenarios.by_scenario`, для каждого сценария — сводный
deficit_index основного горизонта через импортированный `_scenario_deficit_index`
(DRY — не считаем заново). Порядок канонический (`_TG_SCENARIO_ORDER`), но если
встречаются иные имена сценариев — добавляем их в конец. Нет данных → None
(вызывающий просто пропустит строку — graceful, без «нет данных»-шума в чате).
"""
by_scenario = _as_dict(_as_dict(report.get("scenarios")).get("by_scenario"))
if not by_scenario:
return None
ordered = [name for name in _TG_SCENARIO_ORDER if name in by_scenario]
ordered += [name for name in by_scenario if name not in _TG_SCENARIO_ORDER]
parts: list[str] = []
for name in ordered:
label = _TG_SCENARIO_RU.get(name, str(name))
deficit = _scenario_deficit_index(_as_dict(by_scenario.get(name)))
parts.append(f"{label} {_fmt(deficit)}")
return " · ".join(parts) if parts else None
def render_report_telegram_summary(report: Any) -> str:
"""§22 Свернуть `SiteFinderReport` (#987) в КРАТКУЮ Telegram-сводку и вернуть str.
Копипаст-готовый сниппет ~8-15 строк для отправки в чат (длинный MD-отчёт для
Telegram непригоден — здесь только выжимка):
• 1-я строка — заголовок-бренд + кадастр + район;
• вердикт «что строить» (headline) + ключевые числа компактно (индекс дефицита,
мес. предложения, итоговый скор, уверенность RU);
• целевой горизонт + дефицит по сценариям (база/агр/конс) одной строкой, если есть;
• рекомендованный класс продукта (`product_tz.obj_class`), если есть;
• финальная строка — короткий advisory-caveat.
Telegram-safe: PLAIN TEXT с «•»-буллетами, БЕЗ Markdown-таблиц (MarkdownV2 капризен).
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети, БЕЗ новых зависимостей (чистый str — рендер текста;
САМА отправка в Telegram — отдельная инфраструктура, вне scope). Принимает КАК
`SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь (нормализуется `_normalize`).
GRACEFUL: пустой/тонкий/мусорный отчёт → короткая валидная сводка (минимум — заголовок
+ caveat), НИКОГДА не падает (нет KeyError на тонком отчёте).
Args:
report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → минимум).
Returns:
Короткую (≈8-15 строк) plain-text строку, готовую для
`Response(media_type="text/plain; charset=utf-8")` и копипаста в Telegram.
"""
data = _normalize(report)
meta = _as_dict(data.get("meta"))
exec_summary = _as_dict(data.get("exec_summary"))
key_numbers = _as_dict(exec_summary.get("key_numbers"))
product = _as_dict(data.get("product_tz"))
lines: list[str] = []
# 1-я строка: бренд + кадастр + район (компактно, plain text).
cad = _fmt(meta.get("cad_num"))
district = _fmt(meta.get("district"))
lines.append(f"{_TG_TITLE} · {cad} · {district}")
# Вердикт «что строить» (headline) — отдельной строкой, если есть.
headline = exec_summary.get("headline")
if headline:
lines.append(str(headline))
# Ключевые числа компактными буллетами (уверенность — RU; уровень читаем из
# key_numbers, иначе из exec_summary.overall_confidence — оба varianta встречаются).
confidence_level = key_numbers.get("confidence") or exec_summary.get("overall_confidence")
lines.append(f"• Индекс дефицита: {_fmt(key_numbers.get('deficit_index'))}")
lines.append(f"Мес. предложения: {_fmt(key_numbers.get('months_of_inventory'))}")
lines.append(f"• Итоговый скор: {_fmt(key_numbers.get('overall_score'))}")
lines.append(f"• Уверенность: {_level_ru(confidence_level)}")
# Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии).
scenario_line = _tg_scenario_deficit_line(data)
if scenario_line is not None:
horizons = _as_list(meta.get("horizons"))
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else ""
lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}")
# Рекомендованный класс продукта (если задан).
obj_class = product.get("obj_class")
if obj_class:
lines.append(f"• Рекомендованный класс: {_fmt(obj_class)}")
# Финальная строка — короткий advisory-caveat (всегда).
lines.append(_TG_ADVISORY_CAVEAT)
summary = "\n".join(lines)
logger.info(
"render_report_telegram_summary: cad_num=%s lines=%d len=%d advisory=%s",
meta.get("cad_num"),
len(lines),
len(summary),
data.get("advisory"),
)
return summary