init
This commit is contained in:
parent
d66086575a
commit
b95559eac9
57 changed files with 1299 additions and 10 deletions
15
.editorconfig
Normal file
15
.editorconfig
Normal 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
78
.github/workflows/ci.yml
vendored
Normal 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
76
.github/workflows/deploy.yml
vendored
Normal 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
20
Caddyfile
Normal 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
39
Makefile
Normal 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
244
README.md
|
|
@ -1,23 +1,247 @@
|
|||
# Generative Design + Site Finder
|
||||
|
||||
Проект двух продуктовых линий в одной воронке: AI-подбор инвестиционных земельных участков (Site Finder) + автоматическая генерация концепций застройки (Generative Design).
|
||||
Две продуктовые линии в одной воронке: **Site Finder** (AI-подбор инвестиционных земельных участков) + **Generative Design** (автоматическая генерация концепций застройки) для девелоперов РФ.
|
||||
|
||||
## North Star
|
||||
|
||||
К концу месяца 3: **3 платящих пилота** (≥30 тыс. ₽/мес каждый) + заявка в реестр российского ПО → право на Раунд А (20–25 млн ₽).
|
||||
|
||||
Acceptance criteria разбиты на 3 блока (функционально / бизнесово / технически). Правило перехода: 3/3 → Раунд А; 2/3 → продление Фазы 0 на 1–2 мес.; 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 (месяцы 1–3).** Стек: 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 тыс. ₽/мес)** + заявка в реестр российского ПО → право на Раунд А (20–25 млн ₽).
|
||||
## Команда и порядок работы
|
||||
|
||||
**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 (нед. 2–7)
|
||||
- **1a (нед. 2–3):** геометрия + API-контракт. Shapely-сервис (отступы, сетка), Pydantic-схемы, `POST /api/v1/concepts` с заглушкой. Фронт: страница `/concept`, Leaflet, импорт GeoJSON, форма параметров, 3 варианта в табах.
|
||||
- **1b (нед. 4–5):** алгоритм размещения. Жадное заполнение, 3 стратегии (max площадь / max инсоляция / balanced), STRtree для коллизий. Цель ≤10 сек, fallback 15 сек.
|
||||
- **1c (нед. 6–7):** ТЭП + финмодель + экспорты. WeasyPrint PDF, ezdxf, openpyxl. UI кнопок скачивания.
|
||||
|
||||
**Acceptance Stage 1 (конец нед. 7):** полигон → 3 варианта → ТЭП + финмодель → PDF/Excel/DXF. Это уже **продаваемый артефакт** — можно показывать на встречах с месяца 2.
|
||||
|
||||
### Stage 2 — Site Finder MVP (нед. 8–12)
|
||||
- **2a (нед. 8–9):** data pipeline. Парсер ПКК Rosreestr (httpx async, retries), модель `Parcel` с GeoAlchemy2, ≥1000 участков Екатеринбурга. Overpass API для POI, ДОМ.РФ/ЦИАН для цен м², кеш в Redis.
|
||||
- **2b (нед. 10–11):** скоринг + 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 (мес. 1–3): минимальный режим
|
||||
|
||||
**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) |
|
||||
|
||||
**Стоимость:** ~1300–2000 ₽/мес (VPS) + ~17 ₽/мес (домен .ru) = **~1500–2000 ₽/мес**.
|
||||
|
||||
⚠️ **Критично:** настроить ежесуточный pg_dump в первую неделю. Без бэкапа = смерть проекта при первом сбое VM.
|
||||
|
||||
### Триггеры миграции на Yandex Cloud
|
||||
|
||||
Переезжаем на YC Managed PostgreSQL + Compute + Container Registry, когда:
|
||||
- Подписан 3-й платящий пилот (~ конец мес. 3)
|
||||
- Закрылся Раунд А
|
||||
- Идёт переговор с госзаказчиком (нужен реестр ПО + солидная инфра)
|
||||
- БД > 10 GB или появилось > 50 RPS
|
||||
|
||||
Стоимость production на YC: ~10–15 тыс ₽/мес.
|
||||
|
||||
### 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
10
backend/.dockerignore
Normal 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
7
backend/.env.example
Normal 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
34
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
18
backend/app/api/v1/concepts.py
Normal file
18
backend/app/api/v1/concepts.py
Normal 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)
|
||||
20
backend/app/api/v1/parcels.py
Normal file
20
backend/app/api/v1/parcels.py
Normal 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")
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
22
backend/app/core/config.py
Normal file
22
backend/app/core/config.py
Normal 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
23
backend/app/core/db.py
Normal 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
39
backend/app/main.py
Normal 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}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
29
backend/app/models/parcel.py
Normal file
29
backend/app/models/parcel.py
Normal 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
|
||||
)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
46
backend/app/schemas/concept.py
Normal file
46
backend/app/schemas/concept.py
Normal 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]
|
||||
50
backend/app/schemas/parcel.py
Normal file
50
backend/app/schemas/parcel.py
Normal 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]
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/exporters/__init__.py
Normal file
0
backend/app/services/exporters/__init__.py
Normal file
4
backend/app/services/exporters/dxf.py
Normal file
4
backend/app/services/exporters/dxf.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""DXF export via ezdxf.
|
||||
|
||||
Stage 1c: simplified parcel + buildings layout for hand-off to architects.
|
||||
"""
|
||||
4
backend/app/services/exporters/excel.py
Normal file
4
backend/app/services/exporters/excel.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Excel export via openpyxl.
|
||||
|
||||
Stage 1c: 3 sheets — TEAP per variant, Financial model, Comparison.
|
||||
"""
|
||||
4
backend/app/services/exporters/pdf.py
Normal file
4
backend/app/services/exporters/pdf.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""PDF export via WeasyPrint.
|
||||
|
||||
Stage 1c: HTML template with 3 variants + TEAP table + financial table + map snapshots.
|
||||
"""
|
||||
3
backend/app/services/generative/__init__.py
Normal file
3
backend/app/services/generative/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.services.generative import financial, geometry, teap
|
||||
|
||||
__all__ = ["financial", "geometry", "teap"]
|
||||
8
backend/app/services/generative/financial.py
Normal file
8
backend/app/services/generative/financial.py
Normal 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).
|
||||
"""
|
||||
35
backend/app/services/generative/geometry.py
Normal file
35
backend/app/services/generative/geometry.py
Normal 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
|
||||
5
backend/app/services/generative/teap.py
Normal file
5
backend/app/services/generative/teap.py
Normal 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.
|
||||
"""
|
||||
0
backend/app/services/site_finder/__init__.py
Normal file
0
backend/app/services/site_finder/__init__.py
Normal file
5
backend/app/services/site_finder/filters.py
Normal file
5
backend/app/services/site_finder/filters.py
Normal 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).
|
||||
"""
|
||||
5
backend/app/services/site_finder/parser.py
Normal file
5
backend/app/services/site_finder/parser.py
Normal 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.
|
||||
"""
|
||||
9
backend/app/services/site_finder/scorer.py
Normal file
9
backend/app/services/site_finder/scorer.py
Normal 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).
|
||||
"""
|
||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
13
backend/app/workers/celery_app.py
Normal file
13
backend/app/workers/celery_app.py
Normal 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
66
backend/pyproject.toml
Normal 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"
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
35
backend/tests/test_concepts_stub.py
Normal file
35
backend/tests/test_concepts_stub.py
Normal 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",
|
||||
}
|
||||
11
backend/tests/test_health.py
Normal file
11
backend/tests/test_health.py
Normal 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
48
docker-compose.prod.yml
Normal 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
61
docker-compose.yml
Normal 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
6
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.eslintcache
|
||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_MAPBOX_TOKEN=
|
||||
3
frontend/.eslintrc.json
Normal file
3
frontend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
24
frontend/Dockerfile
Normal file
24
frontend/Dockerfile
Normal 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
8
frontend/next.config.ts
Normal 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
36
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
frontend/src/app/concept/page.tsx
Normal file
23
frontend/src/app/concept/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/app/layout.tsx
Normal file
26
frontend/src/app/layout.tsx
Normal 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
18
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/app/site-finder/page.tsx
Normal file
15
frontend/src/app/site-finder/page.tsx
Normal 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
21
frontend/src/lib/api.ts
Normal 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
23
frontend/tsconfig.json
Normal 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
18
sshkey.txt
Normal 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
|
||||
Loading…
Add table
Reference in a new issue