gendesign/backend/app/services/site_finder/weight_profiles.py
lekss361 5aea78c2d8
feat(site-finder): weight profiles service — Pydantic + CRUD (#114 sub-PR 2/4) (#137)
Per #114 sub-PR 2/4. Pydantic v2 models + CRUD service. 14 passing tests.

API: list/get/get_default/create/update/delete_profile + resolve_weights
fallback to system defaults.

Validation: ALLOWED_CATEGORIES guard + weight bounds [-2.0, 3.0].
Default-uniqueness в одной transaction (job_settings.py pattern).

Vault: code/modules/Module_Weight_Profiles_Service.md.

Refs: #114

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 00:29:37 +03:00

338 lines
11 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
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Allowed POI categories — mirrors _POI_WEIGHTS keys in 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 — keep in sync with _POI_WEIGHTS in 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_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, :weights::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 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 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 = :weights::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)