gendesign/backend/app/services/exporters/report_md.py
bot-backend 20efc3bf64
Some checks failed
CI / backend-tests (pull_request) Failing after 9m11s
CI / backend-tests (push) Failing after 9m22s
CI / openapi-codegen-check (push) Successful in 2m14s
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m13s
CI / frontend-tests (push) Successful in 1m2s
CI / openapi-codegen-check (pull_request) Successful in 1m47s
merge: resolve conflicts with main (#1660) into week-review-finish
5 conflicts resolved:
- redaction.py: merged INN checksum + _PHONE_LOCAL_RE + _Repl alias from main
- recommendation.py: kept commercial_sell_through_pct rename (#1635)
- report_pdf.py: kept _scenario_deficit_cell + int|None return type (#1590)
- parcel.py: kept main's detailed docstring (functionally identical)
- alembic/env.py: kept HEAD (app.models import in __init__ already covers all)
2026-06-17 20:21:06 +03:00

554 lines
30 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_horizon,
_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.
Переводы строк в значении сворачиваем в пробел (по аналогии с `_md_cell`):
иначе многострочный value (напр. product_tz.summary) разорвал бы буллет.
"""
if not pairs:
return _NO_DATA
return "\n".join(
f"- **{label}:** {_fmt(value).replace(chr(10), ' ').replace(chr(13), ' ')}"
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 _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. Зеркало report_pdf._scenario_deficit_cell: при fallback `_scenario_deficit_index`
на чужой горизонт (нет 12-мес прогноза) помечаем число реальным горизонтом, чтобы под
общей шапкой «(12 мес)» оно не читалось как 12-месячное. Дефицит None → как есть (→ "").
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
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_cell(data), rate_str, data.get("advisory")])
headers = [
"Сценарий",
f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)",
"Траектория ставки",
"Advisory",
]
return (
f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_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))
# _scenario_deficit_cell сам помечает «(гор. N мес)» при fallback на чужой горизонт,
# чтобы метка периода в чате не лгала (см. _build_scenarios).
deficit = _scenario_deficit_cell(_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:
# Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты
# с основного 12-мес горизонта; при fallback на чужой горизонт у сценария метка
# «(гор. N мес)» уже стоит в самой строке (см. _scenario_deficit_cell), а общая
# метка молчит, чтобы не лгать про период (баг #1590).
by_scenario = _as_dict(_as_dict(data.get("scenarios")).get("by_scenario"))
with_deficit = [
_as_dict(payload)
for payload in by_scenario.values()
if _scenario_deficit_index(_as_dict(payload)) is not None
]
all_primary = bool(with_deficit) and all(
_scenario_deficit_horizon(p) == _PRIMARY_HORIZON_MONTHS for p in with_deficit
)
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if all_primary 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