This commit is contained in:
lekss361 2026-04-25 13:45:19 +03:00
parent d66086575a
commit b95559eac9
57 changed files with 1299 additions and 10 deletions

15
.editorconfig Normal file
View file

@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.py]
indent_size = 4
[Makefile]
indent_style = tab

78
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,78 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: gendesign
POSTGRES_USER: gendesign
POSTGRES_PASSWORD: gendesign
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U gendesign"
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.12
- name: Install system deps for geo + WeasyPrint
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
- name: Install Python deps
run: uv sync
- name: Lint (ruff)
run: uv run ruff check .
- name: Type check (mypy strict on core)
run: |
uv run mypy \
app/services/generative \
app/services/site_finder/scorer.py
- name: Test (pytest)
run: uv run pytest -q
env:
DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci || npm install
- run: npm run lint
- run: npm run type-check
- run: npm run build

76
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set image tag
id: meta
run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Login to Yandex Container Registry
uses: yc-actions/yc-cr-login@v3
with:
yc-sa-json-credentials: ${{ secrets.YC_SA_JSON_CREDENTIALS }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
tags: |
cr.yandex/${{ secrets.YC_REGISTRY_ID }}/backend:latest
cr.yandex/${{ secrets.YC_REGISTRY_ID }}/backend:${{ steps.meta.outputs.tag }}
- name: Build & push frontend
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
tags: |
cr.yandex/${{ secrets.YC_REGISTRY_ID }}/frontend:latest
cr.yandex/${{ secrets.YC_REGISTRY_ID }}/frontend:${{ steps.meta.outputs.tag }}
- name: Deploy to VM via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
script: |
set -euo pipefail
cd /opt/gendesign
# Login to YCR on the host (uses key passed via env or pre-configured docker config)
echo "${{ secrets.YC_SA_JSON_CREDENTIALS }}" | docker login \
--username json_key \
--password-stdin cr.yandex
export IMAGE_TAG=${{ steps.meta.outputs.tag }}
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
# Wait for health
sleep 5
curl -fsS http://localhost:8000/health | head -c 200

20
Caddyfile Normal file
View file

@ -0,0 +1,20 @@
# Replace gendesign.example.ru with your actual domain.
# Caddy will auto-issue Let's Encrypt certificate.
gendesign.example.ru {
encode zstd gzip
# Frontend (Next.js)
handle {
reverse_proxy frontend:3000
}
# Backend API
handle_path /api/* {
reverse_proxy backend:8000
}
handle_path /health {
reverse_proxy backend:8000
}
}

39
Makefile Normal file
View file

@ -0,0 +1,39 @@
.PHONY: help up down logs backend-shell migrate test lint typecheck codegen
help:
@echo "make up - start all services (Postgres, Redis, backend, frontend)"
@echo "make down - stop all services"
@echo "make logs - tail logs from all services"
@echo "make backend-shell - shell into backend container"
@echo "make migrate - run alembic upgrade head"
@echo "make test - run backend pytest"
@echo "make lint - ruff check"
@echo "make typecheck - mypy on core modules"
@echo "make codegen - regenerate frontend TS types from backend OpenAPI"
up:
docker compose up -d --build
down:
docker compose down
logs:
docker compose logs -f --tail=100
backend-shell:
docker compose exec backend bash
migrate:
docker compose exec backend uv run alembic upgrade head
test:
cd backend && uv run pytest -q
lint:
cd backend && uv run ruff check .
typecheck:
cd backend && uv run mypy app/services/generative app/services/site_finder/scorer.py
codegen:
cd frontend && npm run codegen

244
README.md
View file

@ -1,23 +1,247 @@
# Generative Design + Site Finder
Проект двух продуктовых линий в одной воронке: AI-подбор инвестиционных земельных участков (Site Finder) + автоматическая генерация концепций застройки (Generative Design).
Две продуктовые линии в одной воронке: **Site Finder** (AI-подбор инвестиционных земельных участков) + **Generative Design** (автоматическая генерация концепций застройки) для девелоперов РФ.
## North Star
К концу месяца 3: **3 платящих пилота** (≥30 тыс. ₽/мес каждый) + заявка в реестр российского ПО → право на Раунд А (2025 млн ₽).
Acceptance criteria разбиты на 3 блока (функционально / бизнесово / технически). Правило перехода: 3/3 → Раунд А; 2/3 → продление Фазы 0 на 12 мес.; 1/3 или меньше → стоп проекта.
---
## Структура репозитория
```
gendesign/
├── docs/ # Документация и стратегические документы
│ ├── MVP_Goals_Python.md # Цели Discovery MVP, Python-стек (v2.2)
│ └── Бизнес-план v2.1 - Site Finder + Roadmap 12 месяцев.md # Бизнес-план v2.1
└── README.md
├── backend/ # Python 3.12 / FastAPI / PostGIS
│ ├── pyproject.toml # uv-проект, ruff/mypy/pytest конфиги
│ ├── Dockerfile # с системными deps под Geo + WeasyPrint
│ ├── .env.example
│ ├── app/
│ │ ├── main.py # FastAPI + Sentry + /health
│ │ ├── core/ # config (Pydantic Settings), db (SQLAlchemy)
│ │ ├── api/v1/ # concepts.py, parcels.py
│ │ ├── schemas/ # Pydantic-контракты для openapi-typescript
│ │ ├── services/
│ │ │ ├── generative/ # Stage 1: geometry, teap, financial
│ │ │ ├── site_finder/ # Stage 2: parser, scorer, filters
│ │ │ └── exporters/ # Stage 1c: pdf, excel, dxf
│ │ ├── models/parcel.py # GeoAlchemy2 + PostGIS
│ │ └── workers/celery_app.py # Stage 2a: daily PKK sync
│ └── tests/
├── frontend/ # Next.js 15 / React 19 / TypeScript / Leaflet
│ ├── package.json # включая `npm run codegen` → openapi-typescript
│ ├── Dockerfile # multi-stage, output: standalone
│ └── src/app/
│ ├── page.tsx # / — главная
│ ├── concept/page.tsx # /concept — Generative Design
│ └── site-finder/page.tsx # /site-finder — Site Finder
├── memory/
│ └── memory-gendesign.jsonl # хард-линк на C:\mcp\.aim — общий граф памяти
├── docs/ # стратегические документы (бизнес-план, цели MVP)
├── docker-compose.yml # dev: Postgres + Redis + backend + frontend
├── docker-compose.prod.yml # prod: образы из реестра + Caddy reverse-proxy
├── Caddyfile # авто-TLS Let's Encrypt
├── Makefile # make up / down / test / codegen / migrate
└── .github/workflows/
├── ci.yml # ruff + mypy(core) + pytest + frontend build
└── deploy.yml # build → push в реестр → ssh deploy на VM
```
## Текущий статус
---
**Фаза 0 — Discovery MVP (месяцы 13).** Стек: Python 3.12 + FastAPI + PostGIS, Next.js + TypeScript на фронте.
## Технический стек (v2.2)
Подробности — в `docs/MVP_Goals_Python.md`.
**Backend:** Python 3.12, FastAPI, SQLAlchemy 2.0, GeoAlchemy2, Pydantic v2, Alembic, Celery + Redis, httpx
**Geo:** Shapely 2.0, GeoPandas, Pyproj (WGS84 ↔ МСК-66 для Свердловской обл.)
**ML:** scikit-learn (XGBoost — отложен на Фазу 1, в Discovery эвристики)
**БД:** PostgreSQL 16 + PostGIS 3.4, Redis 7
**Frontend:** Next.js 15, React 19, TypeScript 5, Tailwind 4, TanStack Query, Leaflet (Mapbox в Stage 2b)
**Экспорт:** WeasyPrint (PDF), ezdxf (DXF), openpyxl (Excel)
**Dev:** uv, ruff, mypy (strict только на core), pytest
## North Star
---
К концу месяца 3: **3 платящих пилота (≥30 тыс. ₽/мес)** + заявка в реестр российского ПО → право на Раунд А (2025 млн ₽).
## Команда и порядок работы
**2 фуллстек-разработчика, оба part-time 20 ч/нед** — итого ~40 person-hours/нед, эффективных с координацией ~33.
**Ритм:**
- 1 синк/нед (30 мин, понедельник)
- daily-async в Telegram
- PR review обязательный
- Trunk-based: feature ветки ≤ 3 дней
- Codegen TS-типов из Pydantic (`npm run codegen`) — без этого 2 фуллстека плодят расхождения в API-контракте
**Порядок:** строго последовательный — Generative полностью, потом Site Finder.
---
## Этапы MVP (12 недель)
### Stage 0 — Скелет (нед. 1)
Монорепо backend+frontend, Docker с PostGIS+Redis, FastAPI hello, Next.js hello, openapi-typescript codegen, GitHub Actions CI, ruff + mypy(core) + pytest. Без JWT (single-tenant + `X-Demo-User` до Раунда А).
### Stage 1 — Generative MVP (нед. 27)
- **1a (нед. 23):** геометрия + API-контракт. Shapely-сервис (отступы, сетка), Pydantic-схемы, `POST /api/v1/concepts` с заглушкой. Фронт: страница `/concept`, Leaflet, импорт GeoJSON, форма параметров, 3 варианта в табах.
- **1b (нед. 45):** алгоритм размещения. Жадное заполнение, 3 стратегии (max площадь / max инсоляция / balanced), STRtree для коллизий. Цель ≤10 сек, fallback 15 сек.
- **1c (нед. 67):** ТЭП + финмодель + экспорты. WeasyPrint PDF, ezdxf, openpyxl. UI кнопок скачивания.
**Acceptance Stage 1 (конец нед. 7):** полигон → 3 варианта → ТЭП + финмодель → PDF/Excel/DXF. Это уже **продаваемый артефакт** — можно показывать на встречах с месяца 2.
### Stage 2 — Site Finder MVP (нед. 812)
- **2a (нед. 89):** data pipeline. Парсер ПКК Rosreestr (httpx async, retries), модель `Parcel` с GeoAlchemy2, ≥1000 участков Екатеринбурга. Overpass API для POI, ДОМ.РФ/ЦИАН для цен м², кеш в Redis.
- **2b (нед. 1011):** скоринг + API + UI. 3 критерия (расстояние до центра, плотность POI в 1 км, цена района), `POST /api/v1/parcels/search`. Карта с градацией по рейтингу, фильтры, карточка.
- **2c (нед. 12):** интеграция + полировка. Кнопка «Обсчитать концепцию», демо-наполнение 50 участков, бэкапы БД, подача в реестр ПО.
### Что урезано из v2.2 из-за part-time × 2
- XGBoost-скоринг → Фаза 1 (с реальными данными от пилотов)
- JWT-auth с refresh → перед Раундом А
- mypy --strict — только на core (`generative/*`, `site_finder/scorer.py`)
- Время генерации ≤ 15 сек как fallback (а не 10 сек)
---
## Quick start (локально)
**Требования:** Docker Desktop, Node.js 20+, [uv](https://docs.astral.sh/uv/) (для backend dev вне Docker).
```bash
# 1. Скопировать env-файлы
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
# 2. Запустить всё
docker compose up -d --build
# или: make up
# 3. Проверить
curl http://localhost:8000/health
# → {"status":"ok","environment":"dev"}
# Frontend → http://localhost:3000
# Swagger docs → http://localhost:8000/docs
```
**Полезное:**
```bash
make logs # tail логов всех сервисов
make backend-shell # bash внутри backend контейнера
make migrate # alembic upgrade head
make test # pytest
make lint # ruff check
make typecheck # mypy на core
make codegen # обновить TS-типы из OpenAPI
```
---
## Хостинг (production)
### Discovery MVP (мес. 13): минимальный режим
**1× VPS** на Selectel / Timeweb / Beget (2 vCPU / 4 GB / 60 GB SSD), всё в Docker Compose:
| Компонент | Сервис |
|---|---|
| App | backend + frontend (образы из GHCR) |
| БД | Postgres 16 + PostGIS 3.4 (в том же compose) |
| Кэш/очереди | Redis 7 |
| TLS | Caddy с Let's Encrypt автоматически |
| Бэкапы | cron pg_dump → Yandex Object Storage (free tier 1 GB) |
**Стоимость:** ~13002000 ₽/мес (VPS) + ~17 ₽/мес (домен .ru) = **~15002000 ₽/мес**.
⚠️ **Критично:** настроить ежесуточный pg_dump в первую неделю. Без бэкапа = смерть проекта при первом сбое VM.
### Триггеры миграции на Yandex Cloud
Переезжаем на YC Managed PostgreSQL + Compute + Container Registry, когда:
- Подписан 3-й платящий пилот (~ конец мес. 3)
- Закрылся Раунд А
- Идёт переговор с госзаказчиком (нужен реестр ПО + солидная инфра)
- БД > 10 GB или появилось > 50 RPS
Стоимость production на YC: ~1015 тыс ₽/мес.
### GitHub Actions deploy
Workflow `.github/workflows/deploy.yml` собирает образы и через SSH деплоит на VM:
1. Build backend + frontend Docker образов
2. Push в реестр (GHCR для Discovery, YCR после миграции)
3. SSH на VM → `docker compose pull && up -d`
**Secrets** (GitHub → Settings → Secrets and variables → Actions):
- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT` (опц.)
- Для GHCR — `GITHUB_TOKEN` встроенный, отдельный secret не нужен
- Для YC (после миграции) — `YC_SA_JSON_CREDENTIALS`, `YC_REGISTRY_ID`
---
## Память проекта (knowledge graph)
Все решения, факты, риски, KPI, протоколы валидации хранятся **только в графе памяти**, не в .md-документах. Это решает проблему расхождения версий между документами.
**Как устроено:**
- MCP-сервер `mcp-knowledge-graph` пишет в `C:\mcp\.aim\memory-gendesign.jsonl`
- Этот файл — **хард-линк** на `<repo>/memory/memory-gendesign.jsonl`
- Изменения попадают в git автоматически
- Синхронизация между коллегами — через `git pull` / `git push`
**Правило проекта (`Feedback_MemoryFirst`):**
- Все факты и решения сохраняем в граф (`context: "gendesign"`)
- `.md` / `.docx` / `.pdf` Claude **не создаёт сам** — только по явному запросу
- Источник правды = граф, документы = производные срезы
**Рабочий цикл:**
```bash
git pull # перед началом работы
# ... сессия с Claude ...
git add memory/memory-gendesign.jsonl
git commit -m "Memory: <что добавили>"
git push
```
Конфликты в `.jsonl` обычно простые (файл построчный): `git pull --rebase`, оставить **обе** стороны, `git rebase --continue`.
### Подключение коллеги
Краткая инструкция для нового участника команды (полная версия — у владельца репо):
1. Установить Claude Desktop, Node.js 20+, Git
2. Клонировать репо в `C:\Users\<имя>\source\repos\gendesign`
3. `mkdir C:\mcp\.aim`
4. Создать хард-линк:
```powershell
New-Item -ItemType HardLink `
-Path C:\mcp\.aim\memory-gendesign.jsonl `
-Value $PWD\memory\memory-gendesign.jsonl
```
5. Добавить в `%APPDATA%\Claude\claude_desktop_config.json`:
```json
"knowledge-graph": {
"command": "npx",
"args": ["-y", "mcp-knowledge-graph", "--memory-path", "C:\\mcp\\.aim"]
}
```
6. Перезапустить Claude Desktop полностью (через трей)
7. Проверить: «Покажи память контекста gendesign»
---
## Документы стратегии
В `docs/`:
- `MVP_Goals_Python.md` — цели Discovery MVP, Python-стек (v2.2, апрель 2026)
- `Бизнес-план v2.1 - Site Finder + Roadmap 12 месяцев.md` — бизнес-план
⚠️ Эти документы — **снимок состояния на момент написания**. Свежее состояние стратегии — в графе памяти. При расхождении доверяем графу.
---
## Полезные ссылки
- **Репо:** https://github.com/lekss361/-gendesign
- **Целевой регион Discovery:** Свердловская обл. (Екатеринбург, ПЗЗ, МСК-66)
- **Конкуренты:** rTIM (только концепт-стадия, нет site-selection), TestFit (нет РФ-данных)

10
backend/.dockerignore Normal file
View file

@ -0,0 +1,10 @@
.venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
tests
.env
.env.*
!.env.example

7
backend/.env.example Normal file
View file

@ -0,0 +1,7 @@
DATABASE_URL=postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign
REDIS_URL=redis://localhost:6379/0
CORS_ORIGINS=["http://localhost:3000"]
ENVIRONMENT=dev
SENTRY_DSN=
ROSREESTR_PKK_BASE_URL=https://pkk.rosreestr.ru/api/features/1
OVERPASS_URL=https://overpass-api.de/api/interpreter

34
backend/Dockerfile Normal file
View file

@ -0,0 +1,34 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
# Geo + WeasyPrint system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libgdal-dev \
libproj-dev \
libgeos-dev \
libcairo2 \
libpango-1.0-0 \
libpangoft2-1.0-0 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY pyproject.toml ./
COPY uv.lock* ./
RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev
COPY app ./app
COPY alembic.ini* ./
COPY alembic ./alembic 2>/dev/null || true
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View file

View file

View file

View file

@ -0,0 +1,18 @@
from fastapi import APIRouter
from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry
router = APIRouter()
@router.post("", response_model=ConceptOutput)
async def create_concept(payload: ConceptInput) -> ConceptOutput:
"""Generate 3 building variants for the given parcel polygon.
Stage 1a: returns stub with 3 empty variants.
Stage 1b: real greedy placement.
Stage 1c: TEAP + financial model attached.
"""
variants = geometry.generate_stub(payload)
return ConceptOutput(variants=variants)

View file

@ -0,0 +1,20 @@
from fastapi import APIRouter, HTTPException
from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse
router = APIRouter()
@router.post("/search", response_model=ParcelSearchResponse)
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
"""Search parcels by filters + scoring.
TODO Stage 2b: PostGIS query + scorer service.
"""
return ParcelSearchResponse(items=[], total=0)
@router.get("/{parcel_id}", response_model=ParcelDetail)
async def get_parcel(parcel_id: str) -> ParcelDetail:
"""TODO Stage 2b: fetch parcel by id from DB."""
raise HTTPException(status_code=501, detail="Not implemented yet")

View file

View file

@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
database_url: str = (
"postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign"
)
redis_url: str = "redis://localhost:6379/0"
cors_origins: list[str] = ["http://localhost:3000"]
sentry_dsn: str | None = None
environment: str = "dev"
# External APIs (Stage 2)
rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
overpass_url: str = "https://overpass-api.de/api/interpreter"
settings = Settings()

23
backend/app/core/db.py Normal file
View file

@ -0,0 +1,23 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

39
backend/app/main.py Normal file
View file

@ -0,0 +1,39 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator
import sentry_sdk
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import concepts, parcels
from app.core.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
if settings.sentry_dsn:
sentry_sdk.init(
dsn=settings.sentry_dsn,
environment=settings.environment,
traces_sample_rate=0.1,
)
yield
app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment}

View file

View file

@ -0,0 +1,29 @@
"""SQLAlchemy + GeoAlchemy2 ORM models.
Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326);
project to МСК-66 via pyproj when computing distances/areas.
"""
from datetime import datetime
from geoalchemy2 import Geometry
from sqlalchemy import JSON, DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
class Parcel(Base):
__tablename__ = "parcels"
id: Mapped[str] = mapped_column(String, primary_key=True)
cadastral_number: Mapped[str] = mapped_column(String, unique=True, index=True)
vri: Mapped[str] = mapped_column(String, index=True)
area_sqm: Mapped[float] = mapped_column(Float)
address: Mapped[str | None] = mapped_column(String, nullable=True)
geometry: Mapped[object] = mapped_column(Geometry("POLYGON", srid=4326))
enrichment: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)

View file

View file

@ -0,0 +1,46 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class ConceptInput(BaseModel):
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
parcel_geojson: dict[str, Any] = Field(
..., description="GeoJSON Polygon of the parcel (WGS84 / EPSG:4326)"
)
housing_class: Literal["econom", "comfort", "business"] = "comfort"
target_floors: int = Field(9, ge=1, le=30)
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
land_cost_rub: float | None = Field(
None, description="Optional land cost for financial model"
)
class TEAP(BaseModel):
"""Технико-экономические показатели."""
built_area_sqm: float
total_floor_area_sqm: float
residential_area_sqm: float
apartments_count: int
density: float
parking_spaces: int
class FinancialModel(BaseModel):
revenue_rub: float
cost_rub: float
gross_margin_rub: float
irr: float
class ConceptVariant(BaseModel):
strategy: Literal["max_area", "max_insolation", "balanced"]
buildings_geojson: dict[str, Any]
teap: TEAP
financial: FinancialModel
class ConceptOutput(BaseModel):
variants: list[ConceptVariant]

View file

@ -0,0 +1,50 @@
from typing import Any
from pydantic import BaseModel, Field
class ParcelFilter(BaseModel):
vri: list[str] | None = None
area_min_sqm: float | None = None
area_max_sqm: float | None = None
region: str | None = None
polygon_geojson: dict[str, Any] | None = None
text_search: str | None = None
class ScoringWeights(BaseModel):
distance_to_center: float = 0.4
poi_density: float = 0.3
neighborhood_price: float = 0.3
class ParcelSearchRequest(BaseModel):
filters: ParcelFilter = Field(default_factory=ParcelFilter)
weights: ScoringWeights = Field(default_factory=ScoringWeights)
limit: int = 50
class ScoreBreakdown(BaseModel):
total: float
distance_to_center: float
poi_density: float
neighborhood_price: float
class ParcelSummary(BaseModel):
id: str
cadastral_number: str
area_sqm: float
vri: str
address: str | None
score: ScoreBreakdown
class ParcelSearchResponse(BaseModel):
items: list[ParcelSummary]
total: int
class ParcelDetail(ParcelSummary):
geometry_geojson: dict[str, Any]
enrichment: dict[str, Any]

View file

View file

@ -0,0 +1,4 @@
"""DXF export via ezdxf.
Stage 1c: simplified parcel + buildings layout for hand-off to architects.
"""

View file

@ -0,0 +1,4 @@
"""Excel export via openpyxl.
Stage 1c: 3 sheets TEAP per variant, Financial model, Comparison.
"""

View file

@ -0,0 +1,4 @@
"""PDF export via WeasyPrint.
Stage 1c: HTML template with 3 variants + TEAP table + financial table + map snapshots.
"""

View file

@ -0,0 +1,3 @@
from app.services.generative import financial, geometry, teap
__all__ = ["financial", "geometry", "teap"]

View file

@ -0,0 +1,8 @@
"""Financial model.
Stage 1c:
revenue = residential_area_sqm * neighborhood_price_per_sqm
cost = total_floor_area_sqm * construction_cost_per_sqm + land_cost
gross_margin = revenue - cost
base_irr = simplified, no time discounting (defer to Phase 1).
"""

View file

@ -0,0 +1,35 @@
"""Generative Design — geometry placement.
Stage 1a: Shapely-based polygon parsing + normative offsets (buffer).
Stage 1b: greedy filling of rectangular MKD with 3 strategies. STRtree for collisions.
Performance target: <=10s per variant; fallback acceptance 15s.
"""
from app.schemas.concept import ConceptInput, ConceptVariant, FinancialModel, TEAP
def generate_stub(payload: ConceptInput) -> list[ConceptVariant]:
"""Placeholder returning 3 empty variants. Replaced in Stage 1b."""
empty_buildings: dict = {"type": "FeatureCollection", "features": []}
empty_teap = TEAP(
built_area_sqm=0.0,
total_floor_area_sqm=0.0,
residential_area_sqm=0.0,
apartments_count=0,
density=0.0,
parking_spaces=0,
)
empty_financial = FinancialModel(
revenue_rub=0.0, cost_rub=0.0, gross_margin_rub=0.0, irr=0.0
)
strategies: list[ConceptVariant] = []
for strategy in ("max_area", "max_insolation", "balanced"):
strategies.append(
ConceptVariant(
strategy=strategy, # type: ignore[arg-type]
buildings_geojson=empty_buildings,
teap=empty_teap,
financial=empty_financial,
)
)
return strategies

View file

@ -0,0 +1,5 @@
"""TEAP (technical-economic indicators) calculations.
Stage 1c: apartment count by ratios (1/2/3-room), KEP (land use coefficient),
density, parking by simplified norm.
"""

View file

@ -0,0 +1,5 @@
"""Parcel filters (PostGIS queries).
Stage 2b: VRI, area range, polygon containment (ST_Within),
text search by address/cadastral_number (ILIKE).
"""

View file

@ -0,0 +1,5 @@
"""PKK Rosreestr parser.
Stage 2a: httpx async client with retries (tenacity) + rate-limit handling.
Loads >= 1000 Sverdlovsk parcels into PostGIS via SQLAlchemy.
"""

View file

@ -0,0 +1,9 @@
"""Multi-criteria scoring (heuristics, no XGBoost in Discovery).
Stage 2b: weighted sum of normalized 0..100 components:
- distance_to_center
- poi_density (Overpass within 1 km)
- neighborhood_price (DOM.RF / CIAN)
XGBoost deferred to Phase 1 (no training data yet).
"""

View file

View file

@ -0,0 +1,13 @@
"""Celery app + beat schedule.
Stage 2a: daily PKK Rosreestr sync.
"""
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"gendesign", broker=settings.redis_url, backend=settings.redis_url
)
celery_app.conf.timezone = "Europe/Moscow"

66
backend/pyproject.toml Normal file
View file

@ -0,0 +1,66 @@
[project]
name = "gendesign-backend"
version = "0.1.0"
description = "Generative Design + Site Finder backend (FastAPI)"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.30",
"geoalchemy2>=0.15.0",
"alembic>=1.13.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"psycopg[binary]>=3.2.0",
"shapely>=2.0.0",
"geopandas>=1.0.0",
"pyproj>=3.6.0",
"httpx>=0.27.0",
"redis>=5.0.0",
"celery>=5.4.0",
"weasyprint>=62.0",
"ezdxf>=1.3.0",
"openpyxl>=3.1.0",
"pandas>=2.2.0",
"numpy>=2.0.0",
"scikit-learn>=1.5.0",
"sentry-sdk[fastapi]>=2.10.0",
]
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.5.0",
"mypy>=1.10.0",
"types-redis>=4.6.0",
]
[tool.uv]
package = true
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "N", "RUF", "ASYNC"]
[tool.mypy]
python_version = "3.12"
strict_optional = true
warn_unused_ignores = true
# strict только для core-модулей (правило из памяти Validation_Findings_Apr25)
[[tool.mypy.overrides]]
module = [
"app.services.generative.geometry",
"app.services.generative.teap",
"app.services.generative.financial",
"app.services.site_finder.scorer",
]
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

View file

View file

@ -0,0 +1,35 @@
"""Smoke test for the Concept stub. Real algorithm tests come in Stage 1b."""
from fastapi.testclient import TestClient
from app.main import app
def test_concept_stub_returns_three_variants() -> None:
client = TestClient(app)
payload = {
"parcel_geojson": {
"type": "Polygon",
"coordinates": [
[
[60.6, 56.83],
[60.61, 56.83],
[60.61, 56.84],
[60.6, 56.84],
[60.6, 56.83],
]
],
},
"housing_class": "comfort",
"target_floors": 9,
"development_type": "mid_rise",
}
response = client.post("/api/v1/concepts", json=payload)
assert response.status_code == 200
variants = response.json()["variants"]
assert len(variants) == 3
assert {v["strategy"] for v in variants} == {
"max_area",
"max_insolation",
"balanced",
}

View file

@ -0,0 +1,11 @@
from fastapi.testclient import TestClient
from app.main import app
def test_health() -> None:
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"

48
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,48 @@
# Production compose for Yandex Cloud VM (or Selectel cloud server).
# Pull pre-built images from Yandex Container Registry pushed by GitHub Actions.
#
# Place this file at /opt/gendesign/docker-compose.prod.yml on the VM.
# Required env vars provided via /opt/gendesign/.env:
# YC_REGISTRY_ID, IMAGE_TAG, DATABASE_URL, REDIS_URL, SENTRY_DSN,
# NEXT_PUBLIC_API_BASE_URL
services:
backend:
image: cr.yandex/${YC_REGISTRY_ID}/backend:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 5
frontend:
image: cr.yandex/${YC_REGISTRY_ID}/frontend:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
ports:
- "127.0.0.1:3000:3000"
depends_on:
- backend
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- backend
- frontend
volumes:
caddy_data:
caddy_config:

61
docker-compose.yml Normal file
View file

@ -0,0 +1,61 @@
services:
postgres:
image: postgis/postgis:16-3.4
environment:
POSTGRES_DB: gendesign
POSTGRES_USER: gendesign
POSTGRES_PASSWORD: gendesign
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "gendesign"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
backend:
build: ./backend
env_file: ./backend/.env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
ports:
- "8000:8000"
volumes:
- ./backend/app:/app/app
command:
[
"uv",
"run",
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
"--reload",
]
frontend:
build: ./frontend
environment:
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
ports:
- "3000:3000"
depends_on:
- backend
volumes:
postgres_data:
redis_data:

6
frontend/.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
.next
.env
.env.*
!.env.example
.eslintcache

2
frontend/.env.example Normal file
View file

@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_MAPBOX_TOKEN=

3
frontend/.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

24
frontend/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

8
frontend/next.config.ts Normal file
View file

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
};
export default nextConfig;

36
frontend/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "gendesign-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@tanstack/react-query": "^5.50.0",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"leaflet-draw": "^1.0.4"
},
"devDependencies": {
"@types/leaflet": "^1.9.0",
"@types/leaflet-draw": "^1.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.5.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.4.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
"openapi-typescript": "^7.0.0"
}
}

View file

@ -0,0 +1,23 @@
"use client";
import Link from "next/link";
export default function ConceptPage() {
return (
<main style={{ padding: 24, maxWidth: 1200, margin: "0 auto" }}>
<Link href="/"> Home</Link>
<h1>Concept (Generative Design)</h1>
<p>
TODO Stage 1a: polygon import (GeoJSON / Leaflet draw) + parameter form +
result tabs.
</p>
<p>
TODO Stage 1b: render 3 variants on the map with colored buildings.
</p>
<p>
TODO Stage 1c: TEAP table, financial form, PDF/Excel/DXF download
buttons.
</p>
</main>
);
}

View file

@ -0,0 +1,26 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "GenDesign",
description: "Generative Design + Site Finder",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body
style={{
margin: 0,
fontFamily:
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
}}
>
{children}
</body>
</html>
);
}

18
frontend/src/app/page.tsx Normal file
View file

@ -0,0 +1,18 @@
import Link from "next/link";
export default function HomePage() {
return (
<main style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
<h1>GenDesign</h1>
<p>Generative Design + Site Finder Discovery MVP</p>
<ul>
<li>
<Link href="/concept">Concept (Generative Design)</Link>
</li>
<li>
<Link href="/site-finder">Site Finder</Link>
</li>
</ul>
</main>
);
}

View file

@ -0,0 +1,15 @@
"use client";
import Link from "next/link";
export default function SiteFinderPage() {
return (
<main style={{ padding: 24, maxWidth: 1200, margin: "0 auto" }}>
<Link href="/"> Home</Link>
<h1>Site Finder</h1>
<p>TODO Stage 2a: load 1000+ Sverdlovsk parcels into PostGIS.</p>
<p>TODO Stage 2b: map (Mapbox) with color-graded parcels + filters + table + card.</p>
<p>TODO Stage 2c: «Compute concept» button calling Generative module.</p>
</main>
);
}

21
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,21 @@
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
export async function apiFetch<T>(
path: string,
init?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(
`API error ${response.status}: ${await response.text()}`
);
}
return response.json() as Promise<T>;
}

23
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
},
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

18
sshkey.txt Normal file
View file

@ -0,0 +1,18 @@
Your identification has been saved in C:\Users\user\.ssh\gendesign_deploy
Your public key has been saved in C:\Users\user\.ssh\gendesign_deploy.pub
The key fingerprint is:
SHA256:50HjJLdk9XaRDRjvKIB9LrRoux6KojXQZvgqLm7LEks gendesign-deploy
The key's randomart image is:
+--[ED25519 256]--+
| oo.oo|
| o ..o .o|
| . = O + .|
| o o & o + . |
|o + o S B . . |
|.E . . + o |
|.o+ o . |
|B+ o . o |
|@*o ..o |
+----[SHA256]-----+
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPGKuThuQMPdjxdA9FXHkUZUO+iyVbSMOxy3qFbOY7+K gendesign-deploy