gendesign/backend/app/services/site_finder/weight_profiles.py
lekss361 a6e4ff0407 feat(#114): seed 3 default weight presets + include_system API param
Add SQL migration 100_user_weight_profiles_default_seed.sql with system
presets Эконом/Комфорт/Бизнес (user_id='__system__'). Migration is
idempotent via ON CONFLICT DO UPDATE.

Backend:
- weight_profiles.py: add SYSTEM_USER_ID constant + list_profiles_with_system()
- admin_weight_profiles.py: add include_system query param to GET list endpoint

Tests: 3 new tests covering include_system flag and service sentinel behaviour.
2026-05-16 22:49:56 +03:00

372 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""User weight profiles для Site Finder POI scoring.
Per #114 (Макс feedback): кастомизируемые веса для каждой POI категории.
Table schema: data/sql/90_user_weight_profiles.sql.
API surface:
- list_profiles(db, user_id) → list[WeightProfile]
- get_profile(db, user_id, profile_id) → WeightProfile | None
- get_default_profile(db, user_id) → WeightProfile | None
- create_profile(db, payload) → WeightProfile
- update_profile(db, user_id, profile_id, payload) → WeightProfile | None
- delete_profile(db, user_id, profile_id) → bool
- resolve_weights(db, user_id, profile_id) → dict[str, float]
"""
from __future__ import annotations
import json
import logging
import math
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Sentinel user_id для системных preset-профилей (не привязаны к реальному пользователю).
# Seed: data/sql/100_user_weight_profiles_default_seed.sql
SYSTEM_USER_ID: str = "__system__"
# Allowed POI categories — single source of truth; imported by api/v1/parcels.py
ALLOWED_CATEGORIES: set[str] = {
"school",
"kindergarten",
"pharmacy",
"hospital",
"shop_mall",
"shop_supermarket",
"shop_small",
"park",
"bus_stop",
"metro_stop",
"tram_stop",
}
# Weight value bounds (per #114 spec)
MIN_WEIGHT: float = -2.0
MAX_WEIGHT: float = 3.0
# System defaults — single source of truth; imported as _POI_WEIGHTS by api/v1/parcels.py
_SYSTEM_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
"pharmacy": 0.8,
"hospital": 0.6,
"shop_mall": 1.2,
"shop_supermarket": 1.0,
"shop_small": 0.5,
"park": 1.8,
"bus_stop": 0.3,
"metro_stop": 1.5,
"tram_stop": -0.5,
}
# ── SQL helpers ────────────────────────────────────────────────────────────────
_SELECT_COLS = """
id, user_id, profile_name, weights, is_default, description,
created_at, updated_at
"""
_SELECT_BY_USER = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
ORDER BY is_default DESC, id ASC
"""
_SELECT_BY_USER_WITH_SYSTEM = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
OR user_id = :system_user_id
ORDER BY
CASE WHEN user_id = :system_user_id THEN 1 ELSE 0 END ASC,
is_default DESC,
id ASC
"""
_SELECT_BY_ID = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
AND id = :profile_id
"""
_SELECT_DEFAULT = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
AND is_default = TRUE
LIMIT 1
"""
_UNSET_DEFAULT = """
UPDATE user_weight_profiles
SET is_default = FALSE
WHERE user_id = :user_id
AND is_default = TRUE
"""
_INSERT = """
INSERT INTO user_weight_profiles
(user_id, profile_name, weights, is_default, description)
VALUES
(:user_id, :profile_name, CAST(:weights AS jsonb), :is_default, :description)
RETURNING id, created_at, updated_at
"""
# ── Pydantic models ────────────────────────────────────────────────────────────
def _validate_weights_dict(v: dict[str, float]) -> dict[str, float]:
"""Shared validation logic for weights dict."""
bad_keys = set(v.keys()) - ALLOWED_CATEGORIES
if bad_keys:
raise ValueError(f"Unknown POI categories: {sorted(bad_keys)}")
for k, w in v.items():
if not isinstance(w, int | float):
raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}")
if not math.isfinite(w) or w < MIN_WEIGHT or w > MAX_WEIGHT:
raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]")
return v
class WeightProfileBase(BaseModel):
profile_name: str = Field(..., min_length=1, max_length=64)
weights: dict[str, float] = Field(default_factory=dict)
is_default: bool = False
description: str | None = None
@field_validator("weights")
@classmethod
def _validate_weights(cls, v: dict[str, float]) -> dict[str, float]:
return _validate_weights_dict(v)
class WeightProfileCreate(WeightProfileBase):
user_id: str = Field(..., min_length=1, max_length=128)
class WeightProfileUpdate(BaseModel):
profile_name: str | None = Field(None, min_length=1, max_length=64)
weights: dict[str, float] | None = None
is_default: bool | None = None
description: str | None = None
@field_validator("weights")
@classmethod
def _validate_weights(cls, v: dict[str, float] | None) -> dict[str, float] | None:
if v is None:
return v
return _validate_weights_dict(v)
class WeightProfile(WeightProfileBase):
id: int
user_id: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ── Row mapper ─────────────────────────────────────────────────────────────────
def _row_to_profile(r: Any) -> WeightProfile:
"""Map SQLAlchemy mapping row → WeightProfile."""
weights_raw = r["weights"]
if isinstance(weights_raw, str):
weights_raw = json.loads(weights_raw)
weights: dict[str, float] = weights_raw or {}
return WeightProfile(
id=r["id"],
user_id=r["user_id"],
profile_name=r["profile_name"],
weights=weights,
is_default=bool(r["is_default"]),
description=r["description"],
created_at=r["created_at"],
updated_at=r["updated_at"],
)
# ── CRUD service ───────────────────────────────────────────────────────────────
def list_profiles(db: Any, user_id: str) -> list[WeightProfile]:
"""Вернуть все профили пользователя, default первым."""
rows = db.execute(text(_SELECT_BY_USER), {"user_id": user_id}).mappings().all()
return [_row_to_profile(r) for r in rows]
def list_profiles_with_system(db: Any, user_id: str) -> list[WeightProfile]:
"""Вернуть профили пользователя + системные preset-профили.
Пользовательские профили идут первыми (default сверху), затем системные
presets (Эконом, Комфорт, Бизнес). Предназначен для endpoint с
include_system=true — UI dropdown видит и пользовательские, и preset.
"""
rows = (
db.execute(
text(_SELECT_BY_USER_WITH_SYSTEM),
{"user_id": user_id, "system_user_id": SYSTEM_USER_ID},
)
.mappings()
.all()
)
return [_row_to_profile(r) for r in rows]
def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None:
"""Вернуть профиль по id (scoped к пользователю)."""
row = (
db.execute(text(_SELECT_BY_ID), {"user_id": user_id, "profile_id": profile_id})
.mappings()
.first()
)
if row is None:
return None
return _row_to_profile(row)
def get_default_profile(db: Any, user_id: str) -> WeightProfile | None:
"""Вернуть профиль-default пользователя или None."""
row = db.execute(text(_SELECT_DEFAULT), {"user_id": user_id}).mappings().first()
if row is None:
return None
return _row_to_profile(row)
def create_profile(db: Any, payload: WeightProfileCreate) -> WeightProfile:
"""Создать новый профиль.
Если is_default=True — снять is_default у всех существующих профилей
пользователя в одной транзакции.
"""
if payload.is_default:
db.execute(text(_UNSET_DEFAULT), {"user_id": payload.user_id})
result = (
db.execute(
text(_INSERT),
{
"user_id": payload.user_id,
"profile_name": payload.profile_name,
"weights": json.dumps(payload.weights, ensure_ascii=False),
"is_default": payload.is_default,
"description": payload.description,
},
)
.mappings()
.first()
)
db.commit()
assert result is not None, "INSERT RETURNING вернул пустой результат"
return WeightProfile(
id=result["id"],
user_id=payload.user_id,
profile_name=payload.profile_name,
weights=payload.weights,
is_default=payload.is_default,
description=payload.description,
created_at=result["created_at"],
updated_at=result["updated_at"],
)
def update_profile(
db: Any, user_id: str, profile_id: int, payload: WeightProfileUpdate
) -> WeightProfile | None:
"""Обновить поля профиля (PATCH-style). Вернуть None если не найден.
При установке is_default=True — снять is_default у остальных профилей
пользователя в одной транзакции.
"""
existing = get_profile(db, user_id, profile_id)
if existing is None:
return None
sets: list[str] = ["updated_at = NOW()"]
params: dict[str, Any] = {"user_id": user_id, "profile_id": profile_id}
if payload.profile_name is not None:
sets.append("profile_name = :profile_name")
params["profile_name"] = payload.profile_name
if payload.weights is not None:
sets.append("weights = CAST(:weights AS jsonb)")
params["weights"] = json.dumps(payload.weights, ensure_ascii=False)
if payload.description is not None:
sets.append("description = :description")
params["description"] = payload.description
if payload.is_default is not None:
sets.append("is_default = :is_default")
params["is_default"] = payload.is_default
if payload.is_default:
db.execute(text(_UNSET_DEFAULT), {"user_id": user_id})
if len(sets) > 1: # есть что обновлять кроме updated_at
db.execute(
text(
f"UPDATE user_weight_profiles SET {', '.join(sets)}"
" WHERE user_id = :user_id AND id = :profile_id"
),
params,
)
db.commit()
return get_profile(db, user_id, profile_id)
def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
"""Удалить профиль. Вернуть True если удалён, False если не найден."""
result = db.execute(
text(
"DELETE FROM user_weight_profiles"
" WHERE user_id = :user_id AND id = :profile_id"
" RETURNING id"
),
{"user_id": user_id, "profile_id": profile_id},
).first()
if result is None:
return False
db.commit()
return True
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
"""Вернуть эффективные веса для analyze_parcel.
Порядок приоритетов:
1. profile_id задан → загрузить именно этот профиль
2. user_id задан → загрузить default-профиль пользователя
3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS
"""
if profile_id is not None and user_id is not None:
profile = get_profile(db, user_id, profile_id)
if profile is not None and profile.weights:
logger.debug(
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
)
return dict(profile.weights)
if user_id is not None:
profile = get_default_profile(db, user_id)
if profile is not None and profile.weights:
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
return dict(profile.weights)
logger.debug("resolve_weights: returning system defaults")
return dict(_SYSTEM_POI_WEIGHTS)