fix(site-finder): X1 logic bugs from PR #87 re-review

1. score_top_3_negatives — rewrite to unambiguously "most-negative first" via
   explicit `sorted(negatives, key=contribution)[:3]` (ascending sort). Раньше
   использовался trick `[-3:][::-1]` на desc-sorted — давал тот же результат
   для N>=3 negatives, но неинтуитивно читать.
2. _compute_confidence(recent_deals_count) — guard `int(... or 0)` через
   try/except (ValueError, TypeError). Защищает от non-numeric строки в
   external/legacy market_trend payload.
3. Style: `import datetime as _dt` перенесён из function-scope в module-level.

Per auto-review on d0fa8a3.
This commit is contained in:
lekss361 2026-05-12 07:56:03 +03:00
parent d0fa8a3126
commit a6e95b65c2

View file

@ -1,3 +1,4 @@
import datetime as _dt
import json import json
import logging import logging
import math import math
@ -579,8 +580,6 @@ def _compute_confidence(
доступны на main. Композитный балл = avg of subscore'ов; caveats — list доступны на main. Композитный балл = avg of subscore'ов; caveats — list
конкретных проблем для UI ("Нет данных N, score K ненадёжен"). конкретных проблем для UI ("Нет данных N, score K ненадёжен").
""" """
import datetime as _dt
caveats: list[str] = [] caveats: list[str] = []
subscores: dict[str, float] = {} subscores: dict[str, float] = {}
@ -616,9 +615,15 @@ def _compute_confidence(
if not district_row: if not district_row:
caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны") caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны")
# 4) Market trend — есть ли rosreestr_deals # 4) Market trend — есть ли rosreestr_deals.
if market_trend and market_trend.get("recent_deals_count"): # Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric
n_recent = int(market_trend["recent_deals_count"]) # из external/legacy paths; без guard int() крашнет 500.
n_recent_raw = (market_trend or {}).get("recent_deals_count")
try:
n_recent = int(n_recent_raw) if n_recent_raw is not None else 0
except (ValueError, TypeError):
n_recent = 0
if n_recent > 0:
# порог 5 сделок за 6 мес — достаточно для тренда # порог 5 сделок за 6 мес — достаточно для тренда
subscores["market_trend"] = min(1.0, n_recent / 10.0) subscores["market_trend"] = min(1.0, n_recent / 10.0)
if n_recent < 5: if n_recent < 5:
@ -1300,11 +1305,14 @@ def analyze_parcel(
factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True) factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True)
# Convention: оба top-list'а отсортированы "dominant first": # Convention: оба top-list'а отсортированы "dominant first":
# positives → most-positive first (просто [:3] от descending sort) # positives → most-positive first (factors_sorted desc → [:3])
# negatives → most-negative first ([-3:][::-1] = последние три (наиболее # negatives → most-negative first (sort negatives asc → [:3])
# отрицательные) реверсом, чтобы first item был самым большим минусом) # Раньше использовался trick [-3:][::-1] на desc-sorted — это давало тот же
# результат для N>=3 negatives, но был неинтуитивно читать; explicit sort asc
# короче и не зависит от тонкостей slicing.
score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3] score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3]
score_top_3_negatives = [f for f in factors_sorted if f["contribution"] < 0][-3:][::-1] negatives_only = [f for f in factors_sorted if f["contribution"] < 0]
score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3]
# By-group totals — для stacked-bar в UI. count это int, contribution* — float. # By-group totals — для stacked-bar в UI. count это int, contribution* — float.
group_totals: dict[str, dict[str, float | int]] = {} group_totals: dict[str, dict[str, float | int]] = {}