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
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)
554 lines
30 KiB
Python
554 lines
30 KiB
Python
"""§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
|