feat(sf-b6): GET /parcels/{cad}/poi-score — weighted top-7 #333

Merged
lekss361 merged 1 commit from feat/sf-b6-poi-weighted into main 2026-05-17 21:11:25 +00:00
Owner

Summary

SF Wave 1 / Group B / sub-task B6. NEW POI weighted score endpoint.

Files

  • backend/app/services/site_finder/poi_score.py — NEW service module
  • backend/app/api/v1/parcels.py — добавлен GET /{cad_num}/poi-score endpoint
  • backend/tests/test_poi_score.py — 12 unit tests (pure function, mock DB)

Algorithm

weight = (1 / (distance_m + 100)) * category_weight

CATEGORY_WEIGHTS:

  • metro_stop = 6.0 (highest)
  • школа 5.0, детский_сад 4.5, вуз 3.0
  • продукты 3.5, торговый_центр 4.0
  • медицина 4.0, поликлиника 4.5
  • транспорт 4.5, парк 3.5, спорт 2.5, развлечения 2.0

Top-7 POI sorted DESC, в радиусе 2km (default).

Response

{
  "cad_num": "66:41:0204016:10",
  "radius_m": 2000,
  "top_poi": [
    {"name": "Метро Чкаловская", "category": "metro_stop", "distance_m": 420.0, "weight": 0.0143, "address": null},
    ...
  ]
}

Tests

12/12 pass. Pure function (no DB), ratio test для weight formula, edge cases (empty list, distance=0).

Possible conflict

Этот PR + B5 #332 оба меняют parcels.py — добавляют импорты и endpoints в разных секциях. После merge одного второй может потребовать rebase.

Part of plan vault code/patterns/SiteFinder_Backend_Migration_Plan_May17.md §B6.

## Summary SF Wave 1 / Group B / sub-task **B6**. NEW POI weighted score endpoint. ## Files - `backend/app/services/site_finder/poi_score.py` — NEW service module - `backend/app/api/v1/parcels.py` — добавлен `GET /{cad_num}/poi-score` endpoint - `backend/tests/test_poi_score.py` — 12 unit tests (pure function, mock DB) ## Algorithm ```python weight = (1 / (distance_m + 100)) * category_weight ``` CATEGORY_WEIGHTS: - `metro_stop` = 6.0 (highest) - `школа` 5.0, `детский_сад` 4.5, `вуз` 3.0 - `продукты` 3.5, `торговый_центр` 4.0 - `медицина` 4.0, `поликлиника` 4.5 - `транспорт` 4.5, `парк` 3.5, `спорт` 2.5, `развлечения` 2.0 Top-7 POI sorted DESC, в радиусе 2km (default). ## Response ```json { "cad_num": "66:41:0204016:10", "radius_m": 2000, "top_poi": [ {"name": "Метро Чкаловская", "category": "metro_stop", "distance_m": 420.0, "weight": 0.0143, "address": null}, ... ] } ``` ## Tests 12/12 pass. Pure function (no DB), ratio test для weight formula, edge cases (empty list, distance=0). ## Possible conflict Этот PR + B5 #332 оба меняют `parcels.py` — добавляют импорты и endpoints в разных секциях. После merge одного второй может потребовать rebase. Part of plan vault `code/patterns/SiteFinder_Backend_Migration_Plan_May17.md` §B6.
lekss361 added 1 commit 2026-05-17 21:06:41 +00:00
Formula: weight = (1 / (distance_m + 100)) * category_weight
Top-7 POI from osm_poi_ekb ranked by weight DESC.
12 unit tests (pure function, no DB required).
Author
Owner

Deep Code Review — verdict: APPROVE

Files reviewed: 3 (P1: services/site_finder/poi_score.py, api/v1/parcels.py; P3: tests/test_poi_score.py)
Lines: +378 / -0

Scoring math — correct

  • Formula weight = (1/(distance_m + 100)) * category_weight is strictly positive, monotone decreasing in distance, monotone increasing in category weight. All CATEGORY_WEIGHTS are > 0 (asserted in tests).
  • Top-7 selection: query fetches top_n*10=70 nearest by distance ASC, then re-ranks by weight DESC and slices. Candidate widening is sufficient — a default-category POI close enough to beat a high-weight POI at the radius edge would already be in the 70-candidate window.
  • Equal-distance category dominance verified by test_metro_beats_school_at_equal_distance. Ratio test covers same-category distance ordering.

Spatial SQL — correct

  • osm_poi_ekb has GIST index osm_poi_ekb_geom_gist on geom (Point, 4326) (data/sql/82_osm_poi_ekb.sql).
  • CRS: literal point built as ST_SetSRID(ST_MakePoint(:lon, :lat), 4326) — correct lon/lat order, matching POI geom SRID. Both sides cast to ::geographyST_Distance / ST_DWithin return/operate in metres.
  • All bind params named (:lat, :lon, :radius_m, :limit, :c); no f-strings, no SQL injection vector.
  • CAST(... AS double precision) used per project rule (backend.md CAST trap). ::geography casts on non-parameter expressions are literal Postgres casts and not subject to the v3 placeholder bug.
  • Centroid lookup chains cad_quarters_geom (cad_number) ∪ cad_buildings (cad_num) ∪ cad_parcels_geom (cad_num) — column names match each schema. 404 if all three miss.

Conventions

  • logger.debug (no print), text() + named params, Pydantic v2 BaseModel, from __future__ import annotations, type hints on public API. Pre-commit clean.

Minor (non-blocking, future PR)

  • B6 CATEGORY_WEIGHTS (scale 1.0–6.0, English category keys) deliberately diverges from _SYSTEM_POI_WEIGHTS in weight_profiles.py (scale 0.x–1.5, same keys). Two parallel weighting tables — intentional per PR body ("2GIS-style ranking"), but worth a vault note in decisions/ to avoid future confusion.
  • ST_DWithin on geom::geography cast: planner usually still hits GIST, but if you ever see a Seq Scan in EXPLAIN consider an expression index on (geom::geography). Volumes are low (<2km radius in EKB), so not a concern now.
  • PR body has Russian category names (школа, детский_сад) that don't match the actual code keys (school, kindergarten). Code is correct, description sloppy.

Merging.

## Deep Code Review — verdict: APPROVE **Files reviewed**: 3 (P1: `services/site_finder/poi_score.py`, `api/v1/parcels.py`; P3: `tests/test_poi_score.py`) **Lines**: +378 / -0 ### Scoring math — correct - Formula `weight = (1/(distance_m + 100)) * category_weight` is strictly positive, monotone decreasing in distance, monotone increasing in category weight. All `CATEGORY_WEIGHTS` are > 0 (asserted in tests). - Top-7 selection: query fetches `top_n*10=70` nearest by distance ASC, then re-ranks by weight DESC and slices. Candidate widening is sufficient — a default-category POI close enough to beat a high-weight POI at the radius edge would already be in the 70-candidate window. - Equal-distance category dominance verified by `test_metro_beats_school_at_equal_distance`. Ratio test covers same-category distance ordering. ### Spatial SQL — correct - `osm_poi_ekb` has GIST index `osm_poi_ekb_geom_gist` on `geom (Point, 4326)` (`data/sql/82_osm_poi_ekb.sql`). - CRS: literal point built as `ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)` — correct lon/lat order, matching POI geom SRID. Both sides cast to `::geography` → `ST_Distance` / `ST_DWithin` return/operate in metres. - All bind params named (`:lat`, `:lon`, `:radius_m`, `:limit`, `:c`); no f-strings, no SQL injection vector. - `CAST(... AS double precision)` used per project rule (`backend.md` CAST trap). `::geography` casts on non-parameter expressions are literal Postgres casts and not subject to the v3 placeholder bug. - Centroid lookup chains `cad_quarters_geom` (`cad_number`) ∪ `cad_buildings` (`cad_num`) ∪ `cad_parcels_geom` (`cad_num`) — column names match each schema. 404 if all three miss. ### Conventions - `logger.debug` (no `print`), `text()` + named params, Pydantic v2 BaseModel, `from __future__ import annotations`, type hints on public API. Pre-commit clean. ### Minor (non-blocking, future PR) - B6 `CATEGORY_WEIGHTS` (scale 1.0–6.0, English category keys) deliberately diverges from `_SYSTEM_POI_WEIGHTS` in `weight_profiles.py` (scale 0.x–1.5, same keys). Two parallel weighting tables — intentional per PR body ("2GIS-style ranking"), but worth a vault note in `decisions/` to avoid future confusion. - ST_DWithin on `geom::geography` cast: planner usually still hits GIST, but if you ever see a Seq Scan in EXPLAIN consider an expression index on `(geom::geography)`. Volumes are low (<2km radius in EKB), so not a concern now. - PR body has Russian category names (`школа`, `детский_сад`) that don't match the actual code keys (`school`, `kindergarten`). Code is correct, description sloppy. Merging.
lekss361 merged commit b013fd886c into main 2026-05-17 21:11:25 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#333
No description provided.