diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py index 04452b86..b001aa58 100644 --- a/backend/app/services/exporters/report_pdf.py +++ b/backend/app/services/exporters/report_pdf.py @@ -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:

Ключевые числа

{_dict_kv_table(key_numbers)} -

Уверенность отчёта

- {_kv_table(confidence_pairs)} - -

Факторы уверенности

- {_dict_kv_table(factors)} -

Контекст

{_kv_table(context_pairs)} @@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:

Специальные индексы

{_data_table(["Индекс", "Значение", "Метка"], index_rows)} + +""" + + +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""" +
+

{html.escape(_TITLE_CONFIDENCE)}

+ Уровень: {_esc(level)} +

{_esc(rationale)}

+ +

Факторы уверенности

+ {_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
@@ -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""" @@ -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: частичный/ diff --git a/backend/tests/services/exporters/test_report_pdf.py b/backend/tests/services/exporters/test_report_pdf.py index 5ef08748..c71b3b4b 100644 --- a/backend/tests/services/exporters/test_report_pdf.py +++ b/backend/tests/services/exporters/test_report_pdf.py @@ -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 без падения ─────