fix(export): недостающая §13.7 «Уверенность» в PDF-экспортёре отчёта (#2445-C2) (#2456)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m6s
Deploy / build-worker (push) Successful in 4m33s
Deploy / deploy (push) Successful in 2m25s

This commit is contained in:
bot-backend 2026-07-05 19:40:38 +00:00
parent f3988add0a
commit be4b2ee1fa
2 changed files with 152 additions and 22 deletions

View file

@ -32,7 +32,9 @@ from typing import Any
logger = logging.getLogger(__name__)
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
_TITLE_SUMMARY: str = "Сводка"
@ -41,6 +43,7 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
_TITLE_SCENARIOS: str = "Сценарии"
_TITLE_SCORING: str = "Скоринг"
_TITLE_CONFIDENCE: str = "Уверенность"
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
@ -308,15 +311,21 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
def _build_summary(report: dict[str, Any]) -> str:
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
Уровень уверенности здесь только сводный badge (`overall_confidence`), зеркало
report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) раньше
(до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
двух версий одних и тех же данных в одном документе.
"""
exec_summary = _as_dict(report.get("exec_summary"))
meta = _as_dict(report.get("meta"))
confidence = _as_dict(report.get("confidence"))
headline = exec_summary.get("headline")
verdict = exec_summary.get("verdict")
key_numbers = _as_dict(exec_summary.get("key_numbers"))
factors = _as_dict(confidence.get("factors"))
cad = _esc(meta.get("cad_num"))
district = _esc(meta.get("district"))
@ -329,10 +338,6 @@ def _build_summary(report: dict[str, Any]) -> str:
("Сформировано", meta.get("generated_at")),
("Версия схемы", meta.get("schema_version")),
]
confidence_pairs: list[tuple[str, Any]] = [
("Уровень", _level_ru(confidence.get("level"))),
("Обоснование", confidence.get("rationale")),
]
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
return f"""
@ -351,12 +356,6 @@ def _build_summary(report: dict[str, Any]) -> str:
<h3>Ключевые числа</h3>
{_dict_kv_table(key_numbers)}
<h3>Уверенность отчёта</h3>
{_kv_table(confidence_pairs)}
<h3>Факторы уверенности</h3>
{_dict_kv_table(factors)}
<h3>Контекст</h3>
{_kv_table(context_pairs)}
</div>
@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:
<h3>Специальные индексы</h3>
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
</div>
"""
def _build_confidence(report: dict[str, Any]) -> str:
"""§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).
Parity fix (audit epic #2445 item C2): report_docx/report_md уже несли эту секцию
(§22.7/§13.7) PDF был единственным экспортёром без нее. Портировано 1-в-1 (та же
4-колоночная таблица «Фактор/Значение/Уровень/Комментарий»), но через HTML-примитивы
report_pdf (`_data_table`), а не python-docx/Markdown API.
"""
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; иначе кладём как есть
# (зеркало report_docx._build_confidence / report_md._build_confidence).
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"""
<div class="section" id="confidence">
<h2>{html.escape(_TITLE_CONFIDENCE)}</h2>
<span class="badge">Уровень: {_esc(level)}</span>
<p class="verdict">{_esc(rationale)}</p>
<h3>Факторы уверенности</h3>
{_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
</div>
@ -631,7 +667,8 @@ def _build_scoring(report: dict[str, Any]) -> str:
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
_SECTION_BUILDERS: tuple[Any, ...] = (
_build_summary,
_build_market_now,
@ -639,11 +676,12 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
_build_product_tz,
_build_scenarios,
_build_scoring,
_build_confidence,
)
def _build_html(report: dict[str, Any]) -> str:
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
"""Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
return f"""<!DOCTYPE html>
<html lang="ru">
@ -666,10 +704,10 @@ def export_report_pdf(report: Any) -> bytes:
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
рынок / Продукт ТЗ / Сценарии / Скоринг тот же набор, что и `export_report_xlsx`).
Шапки таблиц с заливкой, RU-метки, числа округлены, None "". На блоке «Сводка»
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
`html.escape`.
рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 parity с
report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
округлены, None "". На блоке «Сводка» заметный ADVISORY-маркер (отчёт
советующий). ВСЕ динамические строки экранируются `html.escape`.
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/

View file

@ -24,6 +24,7 @@ import pytest
from app.services.exporters.report_pdf import (
_ADVISORY_MARKER,
_TITLE_CONFIDENCE,
_TITLE_FUTURE_MARKET,
_TITLE_MARKET_NOW,
_TITLE_PRODUCT_TZ,
@ -141,7 +142,8 @@ _skip_if_no_weasyprint = pytest.mark.skipif(
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
# Семь ожидаемых заголовков блоков (по одному на содержательную секцию §13, включая
# §13.7 «Уверенность» — parity fix #2445 C2, зеркало _EXPECTED_TITLES в test_report_docx/md).
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY,
_TITLE_MARKET_NOW,
@ -149,6 +151,7 @@ _EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
_TITLE_CONFIDENCE,
)
@ -357,12 +360,101 @@ class TestContractKeysRendered:
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
def test_confidence_factors_rendered(self) -> None:
# confidence.factors должны попасть в HTML (ключ→значение).
# confidence.factors должны попасть в HTML (ключ→значение) через §13.7-секцию.
html_str = _build_html(_normalize(_full_report()))
assert "Факторы уверенности" in html_str
assert "data_coverage" in html_str
# ── Parity fix #2445 C2: §13.7 «Уверенность» — дедикейтед секция (было только в docx/md) ─
# report_docx.py / report_md.py уже несли полную секцию с 4-колоночной таблицей
# (Фактор/Значение/Уровень/Комментарий) и defensive dict-vs-flat разбором факторов;
# PDF был единственным экспортёром без неё (только тонкий 2-строчный уровень/rationale
# в «Сводке» + плоский dict-дамп факторов). Эти тесты ловят регресс парности.
class TestConfidenceSectionParity:
def test_confidence_section_title_present(self) -> None:
# Секция §13.7 присутствует как отдельный заголовок (не только внутри «Сводки»).
html_str = _build_html(_normalize(_full_report()))
assert _TITLE_CONFIDENCE in html_str
assert 'id="confidence"' in html_str
def test_confidence_section_has_four_column_table(self) -> None:
# 4-колоночная таблица факторов — зеркало report_docx/report_md._build_confidence.
html_str = _build_html(_normalize(_full_report()))
assert "Фактор" in html_str
assert "Значение" in html_str
assert "Уровень" in html_str
assert "Комментарий" in html_str
def test_confidence_level_and_rationale_rendered(self) -> None:
report = SiteFinderReport(
confidence=ReportConfidence(
level="medium",
rationale="Источники advisory-capped; данные средней плотности.",
factors={"data_coverage": 0.6},
),
)
html_str = _build_html(_normalize(report))
assert "средняя" in html_str # _level_ru("medium")
assert "Источники advisory-capped" in html_str
def test_dict_shaped_factor_renders_without_crashing(self) -> None:
# Ровно баг, от которого защищает docx isinstance(payload, dict)-ветка: значение
# фактора — САМО dict {value, level, note} (реальная форма §15/#990, см. frontend
# mock parcel-forecast.json), а не плоский скаляр. Плоский _dict_kv_table в старой
# версии PDF-«Сводки» такое не ожидал — падал/рендерил мусор. Новая §13.7-секция
# (_build_confidence) разбирает dict-payload как в report_docx/report_md.
report = SiteFinderReport(
confidence=ReportConfidence(
level="low",
rationale="Low потому что мало истории.",
factors={
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
"analog_count": {"value": 32, "level": "high", "note": "32 ЖК — достаточно"},
"advisory_capped": False, # плоский (не-dict) фактор — тоже должен уцелеть
},
),
)
# Не должно бросить исключение (регресс-guard); плюс проверяем реальный рендер.
html_str = _build_html(_normalize(report))
assert "history_months" in html_str
assert "6 мес — мало" in html_str
assert "analog_count" in html_str
assert "advisory_capped" in html_str
@_skip_if_no_weasyprint
def test_dict_shaped_factor_produces_valid_pdf(self) -> None:
# То же самое, но конец-в-конец через WeasyPrint (не только HTML-строка).
report = SiteFinderReport(
confidence=ReportConfidence(
level="low",
rationale="Low потому что мало истории.",
factors={
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
},
),
)
payload = export_report_pdf(report)
assert payload.startswith(b"%PDF")
assert len(payload) > 0
def test_summary_no_longer_duplicates_confidence_rationale(self) -> None:
# Тонкий 2-строчный «уровень+rationale» блок убран из «Сводки» — теперь живёт
# только в §13.7, чтобы не было двух версий одних и тех же данных в документе.
# Rationale-текст должен встретиться РОВНО один раз (в секции confidence).
report = SiteFinderReport(
confidence=ReportConfidence(
level="medium",
rationale="Уникальная-строка-обоснования-для-проверки-дублей",
factors={"data_coverage": 0.6},
),
)
html_str = _build_html(_normalize(report))
assert html_str.count("Уникальная-строка-обоснования-для-проверки-дублей") == 1
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────