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
|
# 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/
|
gendesign/
|
||||||
├── docs/ # Документация и стратегические документы
|
├── backend/ # Python 3.12 / FastAPI / PostGIS
|
||||||
│ ├── MVP_Goals_Python.md # Цели Discovery MVP, Python-стек (v2.2)
|
│ ├── pyproject.toml # uv-проект, ruff/mypy/pytest конфиги
|
||||||
│ └── Бизнес-план v2.1 - Site Finder + Roadmap 12 месяцев.md # Бизнес-план v2.1
|
│ ├── Dockerfile # с системными deps под Geo + WeasyPrint
|
||||||
└── README.md
|
│ ├── .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