"""§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