gendesign/backend/app/services/exporters/report_md.py
Light1YT 25f7052084
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m34s
Deploy / deploy (push) Successful in 1m12s
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
feat(export): Markdown + JSON download of §22 forecast report (#959)
render_report_markdown (pure, no new runtime deps) reuses report_pdf's str
helpers (DRY), + GET /{cad_num}/forecast/export?format=md|json. No forecast run
→ 404; graceful on thin/empty reports; GFM-safe table escaping. PDF/XLSX already
existed; this adds the cheapest no-dep formats. Part of EPIC #959.
2026-06-06 18:39:51 +00:00

403 lines
20 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 — не основание для инвест-решения."
# Сколько конкурентов выводить в таблицу (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