feat(site-finder): X1 score breakdown + verbal explain (#47) #87

Closed
lekss361 wants to merge 8 commits from feat/site-finder-score-breakdown into main
Showing only changes of commit a6e95b65c2 - Show all commits

View file

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