fix(site-finder): 5 дефектов из боевого фидбека analyze (#1736 #1737 #1738 #1739 #1740)
Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Failing after 1m50s
CI / frontend-tests (pull_request) Failing after 1m48s
CI / openapi-codegen-check (pull_request) Failing after 2m39s
CI / openapi-codegen-check (push) Failing after 2m46s
CI / backend-tests (pull_request) Successful in 9m25s
CI / backend-tests (push) Successful in 9m30s
Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Failing after 1m50s
CI / frontend-tests (pull_request) Failing after 1m48s
CI / openapi-codegen-check (pull_request) Failing after 2m39s
CI / openapi-codegen-check (push) Failing after 2m46s
CI / backend-tests (pull_request) Successful in 9m25s
CI / backend-tests (push) Successful in 9m30s
- #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy) - #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис» - #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом - #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback - #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит) Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed. Closes #1736 Closes #1737 Closes #1738 Closes #1739 Closes #1740
This commit is contained in:
parent
e7901bc1e8
commit
b1c0ea1268
9 changed files with 349 additions and 100 deletions
|
|
@ -49,6 +49,7 @@ from app.services.exporters.report_md import (
|
|||
render_report_markdown,
|
||||
render_report_telegram_summary,
|
||||
)
|
||||
from app.services.exporters.report_pdf import export_report_pdf
|
||||
from app.services.exporters.report_pptx import render_report_pptx
|
||||
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
||||
from app.services.site_finder.best_layouts import get_best_layouts
|
||||
|
|
@ -244,6 +245,12 @@ PIPELINE_HORIZON_MONTHS = 24
|
|||
PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low
|
||||
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high
|
||||
PIPELINE_TOP_OBJECTS_LIMIT = 10
|
||||
# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок
|
||||
# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться
|
||||
# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию
|
||||
# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может
|
||||
# не совпадать с центроидом ЗУ → буфер ловит «свой» объект).
|
||||
PIPELINE_SELF_EXCLUDE_M = 80
|
||||
|
||||
|
||||
def _coord_round(value: Any) -> float | None:
|
||||
|
|
@ -1163,11 +1170,35 @@ def get_parcel_forecast(
|
|||
return {"status": "pending"}
|
||||
|
||||
if run is not None:
|
||||
report = run.result
|
||||
# #1740: gate↔recommendation — если последний analyze-ран дал gate-вердикт
|
||||
# «Нельзя строить МКД», помечаем forecast-рекомендатор (product_tz) caveat-флагом.
|
||||
# Рекомендацию НЕ удаляем (строгость = продуктовое решение), только делаем
|
||||
# противоречие явным. Best-effort: сбой чтения analyze-рана не валит выдачу форсайта.
|
||||
try:
|
||||
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||||
gate = (analyze_run.result or {}).get("gate_verdict") if analyze_run else None
|
||||
if (
|
||||
isinstance(gate, dict)
|
||||
and gate.get("can_build_mkd") is False
|
||||
and isinstance(report, dict)
|
||||
and isinstance(report.get("product_tz"), dict)
|
||||
):
|
||||
report["product_tz"]["gate_caveat"] = (
|
||||
"Строительство МКД под вопросом (gate: Нельзя) — "
|
||||
"рекомендация носит условный характер"
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"gate_caveat enrich failed for forecast cad=%s — report unaffected",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
return {
|
||||
"status": "ready",
|
||||
"run_id": run.id,
|
||||
"created_at": run.created_at,
|
||||
"report": run.result,
|
||||
"report": report,
|
||||
}
|
||||
|
||||
# Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
|
||||
|
|
@ -1180,14 +1211,14 @@ def export_parcel_forecast(
|
|||
cad_num: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
format: Annotated[
|
||||
Literal["md", "json", "tg", "docx", "pptx"],
|
||||
Literal["md", "json", "tg", "docx", "pptx", "pdf"],
|
||||
Query(
|
||||
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
|
||||
"tg (сводка) | docx (Word-документ) | pptx (презентация)"
|
||||
"tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)"
|
||||
),
|
||||
] = "md",
|
||||
) -> Response:
|
||||
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959).
|
||||
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).
|
||||
|
||||
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
||||
/{cad}/forecast inline) и отдаёт его в нужной форме.
|
||||
|
|
@ -1197,6 +1228,8 @@ def export_parcel_forecast(
|
|||
зеркало содержания md/pdf; python-docx).
|
||||
• format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
||||
сжатое зеркало содержания docx; python-pptx).
|
||||
• format=pdf → `export_report_pdf(run.result)` — attachment (.pdf, §13-отчёт,
|
||||
зеркало содержания md/docx; WeasyPrint).
|
||||
• format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
||||
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
||||
и отправить, а не файл-download).
|
||||
|
|
@ -1208,10 +1241,10 @@ def export_parcel_forecast(
|
|||
|
||||
Args:
|
||||
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
||||
format: "md" (default) | "json" | "tg" | "docx" | "pptx".
|
||||
format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf".
|
||||
|
||||
Returns:
|
||||
Response — для md/json/docx/pptx attachment с Content-Disposition (имя
|
||||
Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя
|
||||
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
||||
"""
|
||||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
|
|
@ -1249,6 +1282,15 @@ def export_parcel_forecast(
|
|||
headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'},
|
||||
)
|
||||
|
||||
# pdf — байты (WeasyPrint), отдельной веткой (зеркало pptx early-return): §13-отчёт
|
||||
# из export_report_pdf (#1739). md/json string-ветки остаются байт-в-байт прежними.
|
||||
if format == "pdf":
|
||||
return Response(
|
||||
content=export_report_pdf(run.result),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{base_name}.pdf"'},
|
||||
)
|
||||
|
||||
if format == "json":
|
||||
payload = json.dumps(run.result, ensure_ascii=False, default=str)
|
||||
media_type = "application/json"
|
||||
|
|
@ -1705,6 +1747,13 @@ def analyze_parcel(
|
|||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
:radius_m
|
||||
)
|
||||
-- #1738: исключаем СУБЪЕКТ анализа — объект на самом участке
|
||||
-- (его точка внутри/в ~80м геометрии ЗУ) не считаем конкурентом.
|
||||
AND NOT ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography,
|
||||
:self_exclude_m
|
||||
)
|
||||
AND ready_dt >= CURRENT_DATE
|
||||
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
|
||||
ORDER BY ready_dt ASC
|
||||
|
|
@ -1712,6 +1761,7 @@ def analyze_parcel(
|
|||
{
|
||||
"wkt": geom_wkt,
|
||||
"radius_m": PIPELINE_RADIUS_M,
|
||||
"self_exclude_m": PIPELINE_SELF_EXCLUDE_M,
|
||||
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
|
||||
},
|
||||
)
|
||||
|
|
@ -2731,6 +2781,26 @@ def analyze_parcel(
|
|||
e,
|
||||
)
|
||||
|
||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner.
|
||||
# Вычисляем ДО сборки result_payload, чтобы при can_build_mkd is False (#1740)
|
||||
# пометить рекомендации флагом-кейватом (противоречие gate↔рекомендация явное).
|
||||
gate_verdict = compute_gate_verdict(
|
||||
nspd_zoning=nspd_dump_data["nspd_zoning"],
|
||||
nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
|
||||
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
||||
nspd_dump=nspd_dump_data["nspd_dump"],
|
||||
)
|
||||
|
||||
# #1740: gate говорит «Нельзя строить МКД», но рекомендатор всё равно выдаёт
|
||||
# квартирографию — не удаляем рекомендацию (строгость = продуктовое решение),
|
||||
# а делаем противоречие явным через caveat-флаг. success_recommendation —
|
||||
# analyze-рекомендатор (топ-квартирография по district).
|
||||
if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None:
|
||||
success_recommendation["gate_caveat"] = (
|
||||
"Строительство МКД под вопросом (gate: Нельзя) — "
|
||||
"рекомендация носит условный характер"
|
||||
)
|
||||
|
||||
result_payload: dict[str, Any] = {
|
||||
"cad_num": cad_num,
|
||||
"source": source,
|
||||
|
|
@ -2827,12 +2897,8 @@ def analyze_parcel(
|
|||
"nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
|
||||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||
"gate_verdict": compute_gate_verdict(
|
||||
nspd_zoning=nspd_dump_data["nspd_zoning"],
|
||||
nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
|
||||
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
||||
nspd_dump=nspd_dump_data["nspd_dump"],
|
||||
),
|
||||
# (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
|
||||
"gate_verdict": gate_verdict,
|
||||
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||
"weights_profile": {
|
||||
"source": _weights_source,
|
||||
|
|
@ -3301,52 +3367,70 @@ def parcel_snapshot_pdf(
|
|||
Не является официальной выпиской ЕГРН — только аналитические данные НСПД.
|
||||
Генерация <2 сек. Открывается в Adobe Reader / Chrome.
|
||||
"""
|
||||
# 1) Получить метаданные участка из cad_parcels
|
||||
parcel_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT readable_address AS address,
|
||||
land_record_area AS area_m2,
|
||||
land_record_category_type AS land_category,
|
||||
permitted_use_established_by_document AS vri,
|
||||
cost_value AS cadastral_cost,
|
||||
updated_at AS last_update
|
||||
FROM cad_parcels
|
||||
WHERE cad_num = CAST(:c AS text)
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"c": cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
if not parcel_row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.",
|
||||
# 1-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты +
|
||||
# district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS-
|
||||
# ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
|
||||
# traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии
|
||||
# геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500).
|
||||
try:
|
||||
# 1) Получить метаданные участка из cad_parcels
|
||||
parcel_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT readable_address AS address,
|
||||
land_record_area AS area_m2,
|
||||
land_record_category_type AS land_category,
|
||||
permitted_use_established_by_document AS vri,
|
||||
cost_value AS cadastral_cost,
|
||||
updated_at AS last_update
|
||||
FROM cad_parcels
|
||||
WHERE cad_num = CAST(:c AS text)
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"c": cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
# 2) Получить геометрию (WKT) для POI / competitor queries
|
||||
geom_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT ST_AsText(COALESCE(
|
||||
(SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1),
|
||||
(SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1)
|
||||
)) AS wkt
|
||||
"""),
|
||||
{"c": cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
geom_wkt: str | None = geom_row["wkt"] if geom_row else None
|
||||
if not parcel_row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"Участок {cad_num} не найден в БД. "
|
||||
"Используйте POST /analyze для загрузки."
|
||||
),
|
||||
)
|
||||
|
||||
# 3) POI в радиусе 1 км (только если есть геометрия)
|
||||
poi_rows: list[dict[str, Any]] = []
|
||||
if geom_wkt:
|
||||
poi_rows = [
|
||||
# 2) Получить геометрию (WKT) для POI / competitor queries
|
||||
geom_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT ST_AsText(COALESCE(
|
||||
(SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1),
|
||||
(SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1)
|
||||
)) AS wkt
|
||||
"""),
|
||||
{"c": cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
geom_wkt: str | None = geom_row["wkt"] if geom_row else None
|
||||
|
||||
# #1739: без геометрии участка PDF-снимок бессмыслен (нет POI/конкурентов/
|
||||
# района). 422 — клиентский кейс «данные неполны», не серверная 500.
|
||||
if not geom_wkt:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Геометрия участка {cad_num} отсутствует в БД — "
|
||||
"PDF-снимок недоступен. Запустите POST /analyze для дозагрузки."
|
||||
),
|
||||
)
|
||||
|
||||
# 3) POI в радиусе 1 км
|
||||
poi_rows: list[dict[str, Any]] = [
|
||||
dict(r)
|
||||
for r in db.execute(
|
||||
text("""
|
||||
|
|
@ -3371,10 +3455,8 @@ def parcel_snapshot_pdf(
|
|||
.all()
|
||||
]
|
||||
|
||||
# 4) Конкуренты в радиусе 3 км (только если есть геометрия)
|
||||
competitor_rows: list[dict[str, Any]] = []
|
||||
if geom_wkt:
|
||||
competitor_rows = [
|
||||
# 4) Конкуренты в радиусе 3 км
|
||||
competitor_rows: list[dict[str, Any]] = [
|
||||
dict(r)
|
||||
for r in db.execute(
|
||||
text("""
|
||||
|
|
@ -3408,9 +3490,8 @@ def parcel_snapshot_pdf(
|
|||
.all()
|
||||
]
|
||||
|
||||
# 5) Получить district (через пересечение с ekb_districts если есть геом)
|
||||
district: str | None = None
|
||||
if geom_wkt:
|
||||
# 5) Получить district (через пересечение с ekb_districts)
|
||||
district: str | None = None
|
||||
district_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
|
|
@ -3427,16 +3508,25 @@ def parcel_snapshot_pdf(
|
|||
if district_row:
|
||||
district = district_row["district_name"]
|
||||
|
||||
# 6) Форматировать last_update
|
||||
raw_update = parcel_row["last_update"]
|
||||
last_update_str: str | None = None
|
||||
if raw_update is not None:
|
||||
try:
|
||||
last_update_str = raw_update.strftime("%d.%m.%Y")
|
||||
except AttributeError:
|
||||
last_update_str = str(raw_update)[:10]
|
||||
# 6) Форматировать last_update
|
||||
raw_update = parcel_row["last_update"]
|
||||
last_update_str: str | None = None
|
||||
if raw_update is not None:
|
||||
try:
|
||||
last_update_str = raw_update.strftime("%d.%m.%Y")
|
||||
except AttributeError:
|
||||
last_update_str = str(raw_update)[:10]
|
||||
except HTTPException:
|
||||
# 404/422 — валидные клиентские ответы, пропускаем без обёртки в 500.
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("snapshot data fetch failed for %s", cad_num)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Ошибка сбора данных для PDF-снимка"
|
||||
) from exc
|
||||
|
||||
# 7) Сгенерировать PDF
|
||||
# 7) Сгенерировать PDF (WeasyPrint-рендер — отдельная зона отказа: шрифты,
|
||||
# шаблон, движок). logger.exception сохраняет traceback причины 500 (#1739).
|
||||
try:
|
||||
pdf_bytes = generate_snapshot_pdf(
|
||||
cad_num=cad_num,
|
||||
|
|
@ -3456,7 +3546,7 @@ def parcel_snapshot_pdf(
|
|||
competitors_limit=5,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc)
|
||||
logger.exception("snapshot PDF render failed for %s", cad_num)
|
||||
raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc
|
||||
|
||||
cad_safe = cad_num.replace(":", "-")
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates)
|
||||
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates"
|
||||
# Путь к директории шаблонов: app/templates/parcel_snapshot.html (#1739).
|
||||
# Файл лежит в app/services/exporters/, поэтому до app/ — 3 уровня вверх
|
||||
# (exporters → services → app), затем templates. parent.parent давал
|
||||
# несуществующий app/services/templates → TemplateNotFound → 500 на snapshot.
|
||||
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent.parent / "templates"
|
||||
|
||||
# Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв)
|
||||
_DEJAVU_CANDIDATES: list[str] = [
|
||||
|
|
@ -61,6 +64,10 @@ def _find_font_url() -> str:
|
|||
|
||||
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
|
||||
поэтому пустая строка допустима — шрифт тогда подбирается CSS generic.
|
||||
|
||||
#1739: при пустой строке шаблон НЕ эмитит `@font-face { src: url('') }`
|
||||
(обёрнут в `{% if font_url %}`). Иначе WeasyPrint пытался резолвить пустой
|
||||
URL → 'failed to load font' ошибка вместо тихого fallback на Arial/sans-serif.
|
||||
"""
|
||||
for path in _DEJAVU_CANDIDATES:
|
||||
if pathlib.Path(path).exists():
|
||||
|
|
|
|||
|
|
@ -42,11 +42,16 @@ JSON-safe и ложится в слот ReportConfidence (#987): {level, rationa
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой
|
||||
# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737).
|
||||
ComponentConfidence = Confidence | tuple[str, Confidence]
|
||||
|
||||
# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
|
||||
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
|
||||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
|
|
@ -61,6 +66,18 @@ _F_CONFOUNDED_WINDOW: str = "confounded_window"
|
|||
_F_ADVISORY_CAP: str = "advisory_cap"
|
||||
_F_COMPONENT: str = "component" # один вкладывающий per-service confidence
|
||||
|
||||
# ── RU-имена вкладывающих под-сервисов (#1737). Машинное имя сервиса (как его кормит
|
||||
# сборщик #988) → человекочитаемое RU-имя для отчёта/UI. Неизвестный/пустой ключ →
|
||||
# дефолт «Компонент» (graceful — не теряем фактор, просто без точечного имени). ──────
|
||||
_SERVICE_RU: dict[str, str] = {
|
||||
"market_metrics": "Рынок",
|
||||
"future_supply": "Будущее предложение",
|
||||
"product_scores": "Скоринг продукта",
|
||||
"special_indices": "Спец-индексы",
|
||||
"forecasts": "Прогноз спрос/предложение",
|
||||
}
|
||||
_SERVICE_RU_DEFAULT: str = "Компонент"
|
||||
|
||||
# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ──────
|
||||
|
||||
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
|
||||
|
|
@ -97,20 +114,29 @@ class ConfidenceFactor:
|
|||
(число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` —
|
||||
его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом
|
||||
(«7 сделок за 6 мес — мало»), из которой собирается структурная причина §15.
|
||||
`label` — человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ
|
||||
под-сервиса — «Рынок» / «Будущее предложение» …, чтобы UI не показывал сырой
|
||||
«Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI).
|
||||
"""
|
||||
|
||||
name: str
|
||||
value: Any # число/доля/флаг, обосновавшие level (или None)
|
||||
level: Confidence
|
||||
note: str # RU, с реальным числом
|
||||
label: str = "" # RU-имя фактора (для component — имя сервиса, #1737); опционально
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
# `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые
|
||||
# потребители без поля `label` продолжают читать name/value/level/note).
|
||||
out: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"value": self.value,
|
||||
"level": self.level,
|
||||
"note": self.note,
|
||||
}
|
||||
if self.label:
|
||||
out["label"] = self.label
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -145,7 +171,12 @@ class ReportConfidenceResult:
|
|||
while f"{key}_{i}" in factors:
|
||||
i += 1
|
||||
key = f"{key}_{i}"
|
||||
factors[key] = {"value": f.value, "level": f.level, "note": f.note}
|
||||
entry: dict[str, Any] = {"value": f.value, "level": f.level, "note": f.note}
|
||||
# RU-имя фактора (для component — имя сервиса, #1737) кладём ТОЛЬКО когда
|
||||
# задано — плоский dict остаётся обратно-совместимым по форме.
|
||||
if f.label:
|
||||
entry["label"] = f.label
|
||||
factors[key] = entry
|
||||
factors["advisory_capped"] = self.advisory_capped
|
||||
return {
|
||||
"level": self.level,
|
||||
|
|
@ -256,17 +287,23 @@ def _confounded_factor(confounded: bool) -> ConfidenceFactor:
|
|||
)
|
||||
|
||||
|
||||
def _component_factor(level: Confidence) -> ConfidenceFactor:
|
||||
def _component_factor(level: Confidence, service: str | None = None) -> ConfidenceFactor:
|
||||
"""Один вкладывающий per-service confidence → ConfidenceFactor. PURE.
|
||||
|
||||
Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
|
||||
нота поясняет вклад. value=None (число — внутри самого под-сервиса).
|
||||
нота поясняет вклад. value=None (число — внутри самого под-сервиса). `service` —
|
||||
машинное имя под-сервиса (#1737): из него берём человекочитаемое RU-имя
|
||||
(«Рынок» / «Будущее предложение» / «Скоринг продукта» / «Спец-индексы»…) в
|
||||
`label` и НАЗЫВАЕМ сервис в `note` (вместо безличного «вкладывающий сервис: high»).
|
||||
Неизвестное/None имя → дефолт «Компонент» (graceful — фактор не теряется).
|
||||
"""
|
||||
ru_name = _SERVICE_RU.get(service or "", _SERVICE_RU_DEFAULT)
|
||||
return ConfidenceFactor(
|
||||
name=_F_COMPONENT,
|
||||
value=None,
|
||||
level=level,
|
||||
note=f"вкладывающий сервис: {level}",
|
||||
note=f"{ru_name}: {level}",
|
||||
label=ru_name,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -370,7 +407,7 @@ def _join_notes(notes: list[str], *, limit: int = 3) -> str:
|
|||
|
||||
def compute_report_confidence(
|
||||
*,
|
||||
component_confidences: list[Confidence] | None = None,
|
||||
component_confidences: Sequence[ComponentConfidence] | None = None,
|
||||
deal_count: int | None = None,
|
||||
analog_count: int | None = None,
|
||||
domrf_coverage: float | None = None,
|
||||
|
|
@ -398,7 +435,9 @@ def compute_report_confidence(
|
|||
|
||||
Args:
|
||||
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
||||
нет вкладывающих компонентов.
|
||||
нет вкладывающих компонентов. Каждый элемент — ЛИБО плоский уровень
|
||||
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
|
||||
имя сервиса проносится сквозь стек в RU-метку фактора).
|
||||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||||
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
||||
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
|
|
@ -451,9 +490,16 @@ def compute_report_confidence(
|
|||
factors.append(_confounded_factor(True))
|
||||
|
||||
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
|
||||
# Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень)
|
||||
# (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень
|
||||
# отбрасываем (whitelist), как и раньше.
|
||||
for c in component_confidences or []:
|
||||
if c in _CONFIDENCE_RANK:
|
||||
factors.append(_component_factor(c))
|
||||
if isinstance(c, tuple):
|
||||
service, level = c
|
||||
else:
|
||||
service, level = None, c
|
||||
if level in _CONFIDENCE_RANK:
|
||||
factors.append(_component_factor(level, service))
|
||||
|
||||
# ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
|
||||
raw_level = _aggregate(factors)
|
||||
|
|
|
|||
|
|
@ -246,23 +246,33 @@ def _component_confidences(
|
|||
forecasts: Sequence[dict[str, Any]],
|
||||
product_scores: dict[str, Any] | None,
|
||||
special_indices: dict[str, Any] | None,
|
||||
) -> list[Confidence]:
|
||||
) -> list[tuple[str, Confidence]]:
|
||||
"""Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE.
|
||||
|
||||
#990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence-
|
||||
метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 /
|
||||
каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986).
|
||||
Только whitelisted 'high|medium|low' (мусор/None отбрасываем).
|
||||
|
||||
#1737: возвращаем ПАРЫ (имя-сервиса, уровень) — машинное имя под-сервиса
|
||||
(«market_metrics» / «future_supply» / «product_scores» / «special_indices» /
|
||||
«forecasts»), чтобы #990 пронёс ИМЯ сервиса в RU-метку фактора и UI не показывал
|
||||
безличный «Компонент N».
|
||||
"""
|
||||
out: list[Confidence] = []
|
||||
for source in (market_metrics, future_supply, product_scores, special_indices):
|
||||
out: list[tuple[str, Confidence]] = []
|
||||
for name, source in (
|
||||
("market_metrics", market_metrics),
|
||||
("future_supply", future_supply),
|
||||
("product_scores", product_scores),
|
||||
("special_indices", special_indices),
|
||||
):
|
||||
conf = _confidence_of(source)
|
||||
if conf is not None:
|
||||
out.append(conf)
|
||||
out.append((name, conf))
|
||||
for f in forecasts:
|
||||
conf = _confidence_of(f)
|
||||
if conf is not None:
|
||||
out.append(conf)
|
||||
out.append(("forecasts", conf))
|
||||
return out
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
<meta charset="UTF-8" />
|
||||
<title>Карточка участка {{ cad_num }}</title>
|
||||
<style>
|
||||
{% if font_url %}
|
||||
@font-face {
|
||||
font-family: 'DejaVu Sans';
|
||||
src: url('{{ font_url }}') format('truetype');
|
||||
}
|
||||
{% endif %}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'DejaVu Sans', Arial, sans-serif;
|
||||
|
|
|
|||
|
|
@ -22,10 +22,33 @@ import datetime as dt
|
|||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
||||
"""Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).
|
||||
|
||||
libpango/libgobject отсутствуют на macOS dev, присутствуют в Docker/CI-образе.
|
||||
Зеркало гварда из tests/services/exporters/test_report_pdf.py — пропускаем
|
||||
PDF-рендер на dev, но прогоняем на CI (#1739).
|
||||
"""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
HTML(string="<p>x</p>").write_pdf()
|
||||
except (OSError, ImportError) as e:
|
||||
return False, str(e)
|
||||
return True, ""
|
||||
|
||||
|
||||
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
|
||||
_skip_if_no_weasyprint = pytest.mark.skipif(
|
||||
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
|
||||
)
|
||||
|
||||
# ── Константы ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_CAD = "66:41:0204016:10"
|
||||
|
|
@ -289,7 +312,11 @@ def test_get_forecast_ready_when_run_present() -> None:
|
|||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
||||
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
|
||||
# здесь None (gate не применяется).
|
||||
with patch(
|
||||
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
|
||||
) as lrf:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -297,8 +324,8 @@ def test_get_forecast_ready_when_run_present() -> None:
|
|||
assert body["status"] == "ready"
|
||||
assert body["run_id"] == 66
|
||||
assert body["report"] == report_dict
|
||||
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam).
|
||||
_, kwargs = lrf.call_args
|
||||
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam) — первый вызов.
|
||||
_, kwargs = lrf.call_args_list[0]
|
||||
assert kwargs.get("schema_version") == "1.0"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
|
@ -590,8 +617,54 @@ def test_export_forecast_no_run_returns_404() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@_skip_if_no_weasyprint
|
||||
def test_export_forecast_pdf_returns_pdf() -> None:
|
||||
"""format=pdf → 200 application/pdf + attachment .pdf, тело — PDF-байты (%PDF) (#1739)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
fake_run = MagicMock()
|
||||
fake_run.result = _REPORT_FIXTURE
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
cd = resp.headers["content-disposition"]
|
||||
assert "attachment;" in cd
|
||||
assert cd.endswith('.pdf"')
|
||||
# cad-двоеточия санитизированы в имени файла.
|
||||
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
|
||||
# WeasyPrint PDF начинается с маркера %PDF.
|
||||
assert resp.content[:4] == b"%PDF"
|
||||
# Читаем именно §22-схему "1.0".
|
||||
_, kwargs = lrf.call_args
|
||||
assert kwargs.get("schema_version") == "1.0"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_export_forecast_pdf_no_run_returns_404() -> None:
|
||||
"""format=pdf + рана нет → 404 (как md/json/docx/pptx export-вариант, НЕ 202/500)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "посчитан" in resp.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_export_forecast_invalid_format_returns_422() -> None:
|
||||
"""format вне {md,json,tg,docx,pptx} → 422 (Literal-валидация ДО работы с БД)."""
|
||||
"""format вне {md,json,tg,docx,pptx,pdf} → 422 (Literal-валидация ДО работы с БД)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -599,7 +672,7 @@ def test_export_forecast_invalid_format_returns_422() -> None:
|
|||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=xml")
|
||||
assert resp.status_code == 422, resp.text
|
||||
lrf.assert_not_called()
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -35,11 +35,15 @@ function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
|
|||
);
|
||||
}
|
||||
|
||||
function factorLabel(key: string): string {
|
||||
function factorLabel(key: string, factor: ConfidenceFactor): string {
|
||||
// #1737: backend проносит RU-имя сервиса в `label` для component-факторов
|
||||
// («Рынок» / «Будущее предложение» / …) — предпочитаем его, чтобы не показывать
|
||||
// безличный «Компонент N».
|
||||
if (factor.label) return factor.label;
|
||||
if (FACTOR_RU[key]) return FACTOR_RU[key];
|
||||
// component_2..N → «Компонент N» (note уже несёт «вкладывающий сервис: …»)
|
||||
const m = key.match(/^component_(\d+)$/);
|
||||
if (m) return `Компонент ${m[1]}`;
|
||||
// Fallback для старых конвертов без `label`: component / component_2..N → «Компонент N».
|
||||
const m = key.match(/^component(?:_(\d+))?$/);
|
||||
if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
|
||||
return key;
|
||||
}
|
||||
|
||||
|
|
@ -142,7 +146,7 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
|
|||
color: "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
{factorLabel(key)}
|
||||
{factorLabel(key, factor)}
|
||||
{valueStr != null && (
|
||||
<span
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import dynamic from "next/dynamic";
|
|||
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { Geometry } from "geojson";
|
||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||
|
||||
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
|
||||
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
|
||||
|
|
@ -87,10 +88,18 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
|
|||
export function MiniMap({ data }: Props) {
|
||||
const adapted = toSiteMapData(data);
|
||||
|
||||
// #1736: точки подключения ресурсов (электро/газ/вода/тепло). Зеркалим
|
||||
// обвязку legacy /site-finder (page.tsx:109,568) — тянем CP-данные по
|
||||
// data.cad_num и пробрасываем prop `connectionPoints` в <SiteMap>, который
|
||||
// рисует ConnectionPointsLayer + CpLayerControlPanel при truthy prop.
|
||||
const { data: connectionPoints } = useConnectionPoints(data.cad_num);
|
||||
|
||||
// #1218: НЕ задаём height + overflow:hidden на обёртке. SiteMap сам
|
||||
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
|
||||
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) лежат
|
||||
// под картой потоком и должны быть видны/кликабельны.
|
||||
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) + панель
|
||||
// точек подключения (CpLayerControlPanel) лежат под картой потоком и должны
|
||||
// быть видны/кликабельны. MINIMAP_HEIGHT ограничивает ТОЛЬКО тайл карты, не
|
||||
// клипает CP-панель.
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -101,6 +110,7 @@ export function MiniMap({ data }: Props) {
|
|||
>
|
||||
<SiteMap
|
||||
data={adapted}
|
||||
connectionPoints={connectionPoints}
|
||||
competitors={data.competitors ?? []}
|
||||
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
|
||||
riskZones={data.nspd_risk_zones ?? []}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,13 @@ export interface ConfidenceFactor {
|
|||
note: string;
|
||||
level: ConfidenceLevel;
|
||||
value: number | null;
|
||||
/**
|
||||
* Human-readable RU label (#1737). For per-service «component» factors this is
|
||||
* the service name («Рынок» / «Будущее предложение» / …) so the UI never shows a
|
||||
* bare «Компонент N». Optional — older envelopes omit it; the UI falls back to
|
||||
* the factor key.
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ReportConfidence {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue