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
2 changed files with 45 additions and 5 deletions
Showing only changes of commit 97dc4ba081 - Show all commits

View file

@ -612,7 +612,9 @@ def analyze_parcel(
else:
center_bonus = 0.0
# X1 (#47): centrality как отдельный synthetic factor в breakdown
# X1 (#47): centrality как отдельный synthetic factor в breakdown.
# NB: для centrality decay не применяется (bonus IS the value), поэтому
# weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus.
if center_bonus > 0:
factors_detailed.append(
{
@ -621,7 +623,7 @@ def analyze_parcel(
"category_ru": "Центральность",
"group": "Локация",
"value": round(dist_to_center_km, 2),
"weight": center_bonus,
"weight": 1.0,
"contribution": round(center_bonus, 2),
"verbal": (
f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — "
@ -1004,11 +1006,15 @@ def analyze_parcel(
f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1)
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 был самым большим минусом)
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]
# By-group totals — для stacked-bar в UI
group_totals: dict[str, dict[str, float]] = {}
# By-group totals — для stacked-bar в UI. count это int, contribution* — float.
group_totals: dict[str, dict[str, float | int]] = {}
for f in factors_detailed:
g = group_totals.setdefault(
f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0}

View file

@ -111,7 +111,8 @@ export function ScoreBreakdownPanel({
color: "#6b7280",
}}
>
{byGroup.map((g) => (
{/* Positive groups — visible в баре */}
{positiveGroups.map((g) => (
<div
key={g.group}
style={{ display: "flex", alignItems: "center", gap: 4 }}
@ -134,6 +135,39 @@ export function ScoreBreakdownPanel({
</div>
))}
</div>
{/* Negative groups отдельной "drag" линией под баром (legend для bar
использует только positive, чтобы не было orphan swatches без сегмента) */}
{byGroup
.filter((g) => g.contribution < 0)
.map((g) => (
<div
key={`neg-${g.group}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 6,
fontSize: 12,
color: "#6b7280",
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
}}
/>
<span>
Снижают балл {g.group}:{" "}
<strong style={{ color: "#dc2626" }}>
{fmtContribution(g.contribution)}
</strong>
</span>
</div>
))}
</div>
)}