@@ -118,7 +118,7 @@ Checkout Docker Installation [docs](./docs/Docker-installation.md)
## Get involved!
-- Start by reading our [Code of conduct](https://github.com/hotosm/fAIr/blob/master/docs/Code-of-Conduct.md)
+- Start by reading our [Code of conduct](docs/Code-of-Conduct.md)
- Get familiar with our [contributor guidelines](CONTRIBUTING.md) explaining the different ways in which you can support this project! We need your help!
# Licenses
diff --git a/backend/ARCHITECTURE.md b/backend/ARCHITECTURE.md
index 24763e386..bbe0e372b 100644
--- a/backend/ARCHITECTURE.md
+++ b/backend/ARCHITECTURE.md
@@ -8,7 +8,7 @@ For the cluster topology, deployment manifests, and ops notes specific to the Ku
## 1. End-to-end user flow
-The user makes five calls.
+The user makes five calls.
```mermaid
flowchart LR
@@ -20,19 +20,19 @@ flowchart LR
What happens behind each step:
-| Step | What the backend does | Output |
-| --- | --- | --- |
-| **1. AOI** | Validates polygon, stores in postgres. | Row in `datasets_aoi`. |
-| **2. Build dataset** | Async worker downloads OAM tiles + OSM labels for the AOI, uploads chips + `labels.geojson` to MinIO, registers a STAC item under `datasets/`. | Built STAC dataset. Poll `GET /datasets/{id}/` until `status=built`. |
+| Step | What the backend does | Output |
+| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **1. AOI** | Validates polygon, stores in postgres. | Row in `datasets_aoi`. |
+| **2. Build dataset** | Async worker downloads OAM tiles + OSM labels for the AOI, uploads chips + `labels.geojson` to MinIO, registers a STAC item under `datasets/`. | Built STAC dataset. Poll `GET /datasets/{id}/` until `status=built`. |
| **3. Submit training** | Async worker submits a ZenML pipeline. ZenML schedules an orchestrator + step pods on the autoscaling ml pool (`split -> train -> eval -> onnx`). Worker polls ZenML status into the DB every 30s. | Trained `weights.pt` + `model.onnx` in MinIO. Poll `GET /trainings/{id}/` until `status=completed`. Tail with `GET /trainings/runs/{run_id}/logs/`. |
-| **4. Promote** | API builds a versioned STAC `local-models/` item from the run's hyperparameters + asset URLs, validates against the base-model's `fair:hyperparameters_spec`, publishes. | `local_model_stac_id`. |
-| **5. Predict** | Async worker downloads chips for the requested bbox, submits an inference pipeline, then post-processes the geojson into `.fgb` + `.pmtiles` via tippecanoe. | Three presigned URLs at `GET /predictions/{id}/result/` once `results_ready=true`. |
+| **4. Promote** | API builds a versioned STAC `local-models/` item from the run's hyperparameters + asset URLs, validates against the base-model's `fair:hyperparameters_spec`, publishes. | `local_model_stac_id`. |
+| **5. Predict** | Async worker downloads chips for the requested bbox, submits an inference pipeline, then post-processes the geojson into `.fgb` + `.pmtiles` via tippecanoe. | Three presigned URLs at `GET /predictions/{id}/result/` once `results_ready=true`. |
### Key invariants
- **Datasets, base-models, and local-models are STAC items.** The postgres tables (`datasets_dataset`, `modelregistry_localmodel`) are thin pointers that hold ownership + lifecycle state. Per-version metadata (assets, hyperparameter specs, mlm:training image, etc.) lives only in STAC.
- **Training and prediction are async.** The `POST /…/submit/` endpoints return 202 + a row whose `zenml_run_id` is null until the worker actually submits the pipeline. Status flow: `initializing -> submitted -> provisioning -> running -> completed | failed | …`.
-- **`results_ready` on a Prediction is a separate flag from `status=completed`.** The worker's `post_run` step generates `.fgb` and `.pmtiles` from the geojson via tippecanoe *after* ZenML reports completion; until that finishes, `/predictions/{id}/result/` returns 409.
+- **`results_ready` on a Prediction is a separate flag from `status=completed`.** The worker's `post_run` step generates `.fgb` and `.pmtiles` from the geojson via tippecanoe _after_ ZenML reports completion; until that finishes, `/predictions/{id}/result/` returns 409.
- **Publish is the only step that writes a versioned local-model STAC item.** It validates `mlm:hyperparameters` (logged by the training pipeline) against the base-model's `fair:hyperparameters_spec`. Any logged key not declared in the spec causes a 500 — the spec must stay in sync with the keys the training image actually emits.
---
@@ -128,6 +128,7 @@ erDiagram
datetime last_polled_at
}
```
+
---
## 3. API reference
@@ -136,38 +137,38 @@ All endpoints live under `/api/v1/`. Writes and owner-scoped reads require `Auth
### Core flow
-| Method | Path | Purpose |
-| --- | --- | --- |
-| GET | `/health/` | Liveness/readiness |
-| POST | `/aois/` | Create an AOI polygon (GeoJSON Feature) |
-| GET | `/aois/` | List AOIs (bbox-filterable) |
-| POST | `/datasets/build/` | Enqueue a dataset build job. `aoi_ids`, `source_imagery` (TMS), `zoom`, `label_tasks`, `label_classes`, `keywords` (allowed: `building`, `road`, `tree`, `water`, `landuse`), `label_type`, `geometry_type` |
-| GET | `/datasets/{id}/?expand=stac` | Inspect dataset, with STAC metadata + presigned `chips`/`labels` URLs once `status=built` |
-| POST | `/datasets/{id}/{publish,unpublish}/` | Toggle dataset `visibility` (anonymous read) |
-| GET | `/local-models/` | List local models (filterable by `status`, `visibility`, `user`) |
-| GET | `/local-models/{id}/runs/` | List ZenML pipeline runs that produced this model |
-| POST | `/local-models/{id}/{publish,unpublish}/` | Toggle local-model `visibility` (anonymous read) |
-| POST | `/trainings/submit/` | Enqueue a finetune. `base_model_stac_id`, `dataset_stac_id`, `model_name`, `overrides` (must match base-model's `fair:hyperparameters_spec`) |
-| GET | `/trainings/{id}/` | Run state including `zenml_run_id` |
-| GET | `/trainings/runs/{run_id}/status/` | Force-poll ZenML; refreshes the DB row |
-| GET | `/trainings/runs/{run_id}/logs/?tail=N&step=name` | Tail orchestrator or step logs |
-| POST | `/trainings/runs/{run_id}/cancel/?graceful=true` | Stop a running pipeline |
-| POST | `/trainings/{id}/publish/` | Promote a completed run to a versioned local-model. Validates against base-model spec; writes a new STAC item under `local-models/`. Returns `local_model_stac_id` |
-| POST | `/predictions/submit/` | Enqueue inference. `model_stac_id`, `image_uri` (TMS), `bbox`, `zoom`, `params`, `remove_osm` |
-| GET | `/predictions/{id}/` | Status + assets (presigned) once `results_ready=true` |
-| GET | `/predictions/{id}/result/` | Just the three presigned URLs (geojson / fgb / pmtiles); 409 until `results_ready` |
-| GET | `/predictions/runs/{run_id}/{status,logs}/` | Same shape as trainings |
-| POST | `/predictions/{id}/{publish,unpublish}/` | Toggle prediction `visibility` (anonymous read of result) |
+| Method | Path | Purpose |
+| ------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| GET | `/health/` | Liveness/readiness |
+| POST | `/aois/` | Create an AOI polygon (GeoJSON Feature) |
+| GET | `/aois/` | List AOIs (bbox-filterable) |
+| POST | `/datasets/build/` | Enqueue a dataset build job. `aoi_ids`, `source_imagery` (TMS), `zoom`, `label_tasks`, `label_classes`, `keywords` (allowed: `building`, `road`, `tree`, `water`, `landuse`), `label_type`, `geometry_type` |
+| GET | `/datasets/{id}/?expand=stac` | Inspect dataset, with STAC metadata + presigned `chips`/`labels` URLs once `status=built` |
+| POST | `/datasets/{id}/{publish,unpublish}/` | Toggle dataset `visibility` (anonymous read) |
+| GET | `/local-models/` | List local models (filterable by `status`, `visibility`, `user`) |
+| GET | `/local-models/{id}/runs/` | List ZenML pipeline runs that produced this model |
+| POST | `/local-models/{id}/{publish,unpublish}/` | Toggle local-model `visibility` (anonymous read) |
+| POST | `/trainings/submit/` | Enqueue a finetune. `base_model_stac_id`, `dataset_stac_id`, `model_name`, `overrides` (must match base-model's `fair:hyperparameters_spec`) |
+| GET | `/trainings/{id}/` | Run state including `zenml_run_id` |
+| GET | `/trainings/runs/{run_id}/status/` | Force-poll ZenML; refreshes the DB row |
+| GET | `/trainings/runs/{run_id}/logs/?tail=N&step=name` | Tail orchestrator or step logs |
+| POST | `/trainings/runs/{run_id}/cancel/?graceful=true` | Stop a running pipeline |
+| POST | `/trainings/{id}/publish/` | Promote a completed run to a versioned local-model. Validates against base-model spec; writes a new STAC item under `local-models/`. Returns `local_model_stac_id` |
+| POST | `/predictions/submit/` | Enqueue inference. `model_stac_id`, `image_uri` (TMS), `bbox`, `zoom`, `params`, `remove_osm` |
+| GET | `/predictions/{id}/` | Status + assets (presigned) once `results_ready=true` |
+| GET | `/predictions/{id}/result/` | Just the three presigned URLs (geojson / fgb / pmtiles); 409 until `results_ready` |
+| GET | `/predictions/runs/{run_id}/{status,logs}/` | Same shape as trainings |
+| POST | `/predictions/{id}/{publish,unpublish}/` | Toggle prediction `visibility` (anonymous read of result) |
### Schema endpoints
-| Method | Path |
-| --- | --- |
-| GET | `/api/schema/` (raw OpenAPI) |
-| GET | `/api/docs/` (Swagger UI) |
-| GET | `/api/redoc/` (ReDoc) |
+| Method | Path |
+| ------ | ---------------------------- |
+| GET | `/api/schema/` (raw OpenAPI) |
+| GET | `/api/docs/` (Swagger UI) |
+| GET | `/api/redoc/` (ReDoc) |
-### User test
+### User test
Send these one at a time from Swagger (`/api/docs/`) or any HTTP client. Every request needs `Authorization: Bearer `. Substitute the IDs returned from each step into the next.
@@ -178,11 +179,15 @@ Send these one at a time from Swagger (`/api/docs/`) or any HTTP client. Every r
"type": "Feature",
"geometry": {
"type": "Polygon",
- "coordinates": [[
- [85.51678, 27.63133], [85.52323, 27.63133],
- [85.52323, 27.63743], [85.51678, 27.63743],
- [85.51678, 27.63133]
- ]]
+ "coordinates": [
+ [
+ [85.51678, 27.63133],
+ [85.52323, 27.63133],
+ [85.52323, 27.63743],
+ [85.51678, 27.63743],
+ [85.51678, 27.63133]
+ ]
+ ]
},
"properties": { "dataset": null }
}
diff --git a/backend/README.md b/backend/README.md
index 52b07e5bf..3324a4950 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -18,11 +18,11 @@ run Django outside it:
cd ..
docker compose up -d postgres minio stac mlflow zenml
cd backend
-just setup
-cp env_example .env
+just setup
+cp env_example .env
just migrate
-just run
-just worker tasks
+just run
+just worker # runs async jobs (django_tasks)
```
The two sample files differ only in host names: the root `env_example` uses
@@ -37,19 +37,19 @@ OpenAPI schema at `/api/schema/`, Swagger UI at `/api/docs/`, ReDoc at `/api/red
### Core Django
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `DEBUG` | no | `false` | Django debug mode. In prod (`false`), `SECRET_KEY` must be at least 32 chars and not contain `dev` or `unsafe`. |
-| `SECRET_KEY` | yes | (none) | Django secret. Strict length/strength check when `DEBUG=false`. |
-| `DATABASE_URL` | yes | (none) | Postgres URL. Scheme must be `postgres`, `postgresql`, or `postgis`. |
-| `DATABASE_SSL_MODE` | no | `null` | psycopg `sslmode`. Set to `require` for hosted Postgres. |
-| `ALLOWED_HOSTS` | no | `[]` | Comma-separated list. |
-| `CSRF_TRUSTED_ORIGINS` | no | `[]` | Comma-separated list. |
-| `CORS_ALLOWED_ORIGINS` | no | `[]` | Comma-separated list. |
-| `SECURE_SSL_REDIRECT` | no | `true` | Force HTTPS redirect at the Django layer. |
-| `FRONTEND_URL` | yes | (none) | Public URL of the SPA. Used in emails and CORS. |
-| `API_BASE_URL` | yes | (none) | Public URL of this backend (e.g. `http://localhost:8000/api/v1`). |
-| `HOSTNAME` | no | `127.0.0.1` | Used by the OpenAPI server URL. |
+| Name | Required | Default | Description |
+| ---------------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
+| `DEBUG` | no | `false` | Django debug mode. In prod (`false`), `SECRET_KEY` must be at least 32 chars and not contain `dev` or `unsafe`. |
+| `SECRET_KEY` | yes | (none) | Django secret. Strict length/strength check when `DEBUG=false`. |
+| `DATABASE_URL` | yes | (none) | Postgres URL. Scheme must be `postgres`, `postgresql`, or `postgis`. |
+| `DATABASE_SSL_MODE` | no | `null` | psycopg `sslmode`. Set to `require` for hosted Postgres. |
+| `ALLOWED_HOSTS` | no | `[]` | Comma-separated list. |
+| `CSRF_TRUSTED_ORIGINS` | no | `[]` | Comma-separated list. |
+| `CORS_ALLOWED_ORIGINS` | no | `[]` | Comma-separated list. |
+| `SECURE_SSL_REDIRECT` | no | `true` | Force HTTPS redirect at the Django layer. |
+| `FRONTEND_URL` | yes | (none) | Public URL of the SPA. Used in emails and CORS. |
+| `API_BASE_URL` | yes | (none) | Public URL of this backend (e.g. `http://localhost:8000/api/v1`). |
+| `HOSTNAME` | no | `127.0.0.1` | Used by the OpenAPI server URL. |
### Authentication
@@ -57,19 +57,19 @@ OpenAPI schema at `/api/schema/`, Swagger UI at `/api/docs/`, ReDoc at `/api/red
`GET` on datasets, local-models, and predictions is open to anonymous callers for rows with `visibility="public"`. Owner-scoped lifecycle data (AOIs, trainings, feedback, notifications) and every write require Bearer auth.
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `AUTH_PROVIDER` | no | `hanko` | One of `hanko`, `dev`. |
-| `FAIR_DEV_TOKEN` | when `AUTH_PROVIDER=dev` | `null` | Static dev token. Generate with `openssl rand -hex 32`. Never commit. |
-| `HANKO_API_URL` | when `AUTH_PROVIDER=hanko` | `null` | Hanko backend URL. |
-| `COOKIE_SECRET` | when `AUTH_PROVIDER=hanko` | `null` | Used to verify Hanko-signed cookies. |
-| `COOKIE_DOMAIN` | no | `null` | Cookie scope domain. |
-| `COOKIE_SECURE` | no | `null` | Force `Secure` cookie flag. |
-| `JWT_AUDIENCE` | no | `null` | Expected `aud` claim. |
-| `LOGIN_URL` | no | `https://login.hotosm.org` | HOT login portal URL. |
-| `LOGIN_INTERNAL_API_KEY` | no | `null` | Server-to-server key against the login portal. |
-| `LOGIN_BACKEND_URL` | no | `null` | Login portal backend URL. |
-| `OSM_LOGIN_REDIRECT_URI` | no | `null` | Required only when Hanko's "connect existing OSM account" flow is enabled. |
+| Name | Required | Default | Description |
+| ------------------------ | -------------------------- | -------------------------- | -------------------------------------------------------------------------- |
+| `AUTH_PROVIDER` | no | `hanko` | One of `hanko`, `dev`. |
+| `FAIR_DEV_TOKEN` | when `AUTH_PROVIDER=dev` | `null` | Static dev token. Generate with `openssl rand -hex 32`. Never commit. |
+| `HANKO_API_URL` | when `AUTH_PROVIDER=hanko` | `null` | Hanko backend URL. |
+| `COOKIE_SECRET` | when `AUTH_PROVIDER=hanko` | `null` | Used to verify Hanko-signed cookies. |
+| `COOKIE_DOMAIN` | no | `null` | Cookie scope domain. |
+| `COOKIE_SECURE` | no | `null` | Force `Secure` cookie flag. |
+| `JWT_AUDIENCE` | no | `null` | Expected `aud` claim. |
+| `LOGIN_URL` | no | `https://login.hotosm.org` | HOT login portal URL. |
+| `LOGIN_INTERNAL_API_KEY` | no | `null` | Server-to-server key against the login portal. |
+| `LOGIN_BACKEND_URL` | no | `null` | Login portal backend URL. |
+| `OSM_LOGIN_REDIRECT_URI` | no | `null` | Required only when Hanko's "connect existing OSM account" flow is enabled. |
### fair-py-ops (ZenML + STAC)
@@ -77,105 +77,105 @@ OpenAPI schema at `/api/schema/`, Swagger UI at `/api/docs/`, ReDoc at `/api/red
the `zenml` library itself when it opens a connection, so both sets point at the
same server. Authenticate with either an API key or a username and password.
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `FAIR_ZENML_STORE_URL` | yes (at runtime) | `null` | URL of the deployed ZenML server. Optional at boot, raises loud at first call site. |
-| `FAIR_STAC_API_URL` | yes (at runtime) | `null` | URL of the STAC API root (eoapi-stac-fastapi). Trailing slashes are stripped. |
-| `FAIR_STAC_API_KEY` | prod | `null` | Bearer token for the STAC Transactions extension. |
-| `ZENML_STORE_URL` | yes (at runtime) | `null` | Same server as `FAIR_ZENML_STORE_URL`. |
-| `ZENML_STORE_API_KEY` | one of the two | `null` | Service-account key. Mint with `zenml service-account create fair-backend`. |
-| `ZENML_STORE_USERNAME` | one of the two | `null` | Username, paired with `ZENML_STORE_PASSWORD`. The compose stack's default user is `default` with an empty password. |
-| `ZENML_STORE_PASSWORD` | with username | `null` | Password for the above. |
+| Name | Required | Default | Description |
+| ---------------------- | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
+| `FAIR_ZENML_STORE_URL` | yes (at runtime) | `null` | URL of the deployed ZenML server. Optional at boot, raises loud at first call site. |
+| `FAIR_STAC_API_URL` | yes (at runtime) | `null` | URL of the STAC API root (eoapi-stac-fastapi). Trailing slashes are stripped. |
+| `FAIR_STAC_API_KEY` | prod | `null` | Bearer token for the STAC Transactions extension. |
+| `ZENML_STORE_URL` | yes (at runtime) | `null` | Same server as `FAIR_ZENML_STORE_URL`. |
+| `ZENML_STORE_API_KEY` | one of the two | `null` | Service-account key. Mint with `zenml service-account create fair-backend`. |
+| `ZENML_STORE_USERNAME` | one of the two | `null` | Username, paired with `ZENML_STORE_PASSWORD`. The compose stack's default user is `default` with an empty password. |
+| `ZENML_STORE_PASSWORD` | with username | `null` | Password for the above. |
### Object storage (S3 / MinIO)
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `BUCKET_NAME` | yes (at runtime) | `null` | S3 / MinIO bucket. |
-| `PARENT_BUCKET_FOLDER` | no | `dev` | Per-environment prefix inside the bucket. |
-| `AWS_REGION` | no | `us-east-1` | S3 region. |
-| `AWS_ACCESS_KEY_ID` | yes (at runtime) | `null` | |
-| `AWS_SECRET_ACCESS_KEY` | yes (at runtime) | `null` | |
-| `AWS_ENDPOINT_URL` | no | `null` | Set for non-AWS S3 (MinIO, Cloudflare R2, etc.). |
-| `PRESIGNED_URL_EXPIRY` | no | `900` | Presigned-URL TTL in seconds. |
+| Name | Required | Default | Description |
+| ----------------------- | ---------------- | ----------- | ------------------------------------------------ |
+| `BUCKET_NAME` | yes (at runtime) | `null` | S3 / MinIO bucket. |
+| `PARENT_BUCKET_FOLDER` | no | `dev` | Per-environment prefix inside the bucket. |
+| `AWS_REGION` | no | `us-east-1` | S3 region. |
+| `AWS_ACCESS_KEY_ID` | yes (at runtime) | `null` | |
+| `AWS_SECRET_ACCESS_KEY` | yes (at runtime) | `null` | |
+| `AWS_ENDPOINT_URL` | no | `null` | Set for non-AWS S3 (MinIO, Cloudflare R2, etc.). |
+| `PRESIGNED_URL_EXPIRY` | no | `900` | Presigned-URL TTL in seconds. |
### Rate limits + database pool
DRF throttle scopes use `/`, e.g. `1000/h`. Use the Django-native pool OR PgBouncer, not both.
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `USER_RATE_LIMIT` | no | `1000/h` | Authenticated requests. |
-| `ANON_RATE_LIMIT` | no | `100/h` | Anonymous requests. |
-| `TRAINING_RATE_LIMIT` | no | `10/h` | Training submission throttle. |
-| `PREDICTION_RATE_LIMIT` | no | `50/h` | Prediction submission throttle. |
-| `DB_POOL_MIN_SIZE` | no | `4` | Django 5.1+ native PostgreSQL connection pool minimum. |
-| `DB_POOL_MAX_SIZE` | no | `20` | Pool maximum. |
-| `DB_POOL_TIMEOUT` | no | `30` | Seconds to wait for a free connection. |
+| Name | Required | Default | Description |
+| ----------------------- | -------- | -------- | ------------------------------------------------------ |
+| `USER_RATE_LIMIT` | no | `1000/h` | Authenticated requests. |
+| `ANON_RATE_LIMIT` | no | `100/h` | Anonymous requests. |
+| `TRAINING_RATE_LIMIT` | no | `10/h` | Training submission throttle. |
+| `PREDICTION_RATE_LIMIT` | no | `50/h` | Prediction submission throttle. |
+| `DB_POOL_MIN_SIZE` | no | `4` | Django 5.1+ native PostgreSQL connection pool minimum. |
+| `DB_POOL_MAX_SIZE` | no | `20` | Pool maximum. |
+| `DB_POOL_TIMEOUT` | no | `30` | Seconds to wait for a free connection. |
### OSM raw-data API
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `RAW_DATA_API_URL` | no | `https://api-prod.raw-data.hotosm.org/v1` | HOT Raw Data API root. |
+| Name | Required | Default | Description |
+| ------------------ | -------- | ----------------------------------------- | ---------------------- |
+| `RAW_DATA_API_URL` | no | `https://api-prod.raw-data.hotosm.org/v1` | HOT Raw Data API root. |
### Mapswipe
Off by default. When `ENABLE_MAPSWIPE=false`, `POST /api/v1/predictions//mapswipe/` returns 503. The `/api/v1/health/` endpoint reports `mapswipe.reachable` when enabled.
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `ENABLE_MAPSWIPE` | no | `false` | Master toggle. |
-| `MAPSWIPE_BACKEND_URL` | no | `null` | Mapswipe backend URL. |
-| `MAPSWIPE_MANAGER_URL` | no | `null` | Mapswipe manager URL. |
-| `MAPSWIPE_WEB_URL` | no | `null` | Mapswipe web app URL. |
-| `MAPSWIPE_CSRFTOKEN_KEY` | when `ENABLE_MAPSWIPE=true` | `null` | CSRF cookie name on the Mapswipe backend. |
-| `MAPSWIPE_FB_AUTH_URL` | when `ENABLE_MAPSWIPE=true` | `null` | Firebase auth endpoint. |
-| `MAPSWIPE_FB_USERNAME` | when `ENABLE_MAPSWIPE=true` | `null` | Firebase service-account username. |
-| `MAPSWIPE_FB_PASSWORD` | when `ENABLE_MAPSWIPE=true` | `null` | Firebase service-account password. |
-| `MAPSWIPE_TUTORIAL_ID` | no | `37` | Mapswipe tutorial ID injected into pushed projects. |
-| `MAPSWIPE_ORGANIZATION_ID` | no | `4` | Mapswipe organization ID. |
-| `MAPSWIPE_VERIFICATION_NUMBER` | no | `3` | Number of crowd verifications required per tile. |
-| `MAPSWIPE_POLL_INTERVAL` | no | `10` | Seconds between Mapswipe push-status polls. |
-| `MAPSWIPE_POLL_TIMEOUT` | no | `600` | Maximum total seconds to wait for a Mapswipe push. |
+| Name | Required | Default | Description |
+| ------------------------------ | --------------------------- | ------- | --------------------------------------------------- |
+| `ENABLE_MAPSWIPE` | no | `false` | Master toggle. |
+| `MAPSWIPE_BACKEND_URL` | no | `null` | Mapswipe backend URL. |
+| `MAPSWIPE_MANAGER_URL` | no | `null` | Mapswipe manager URL. |
+| `MAPSWIPE_WEB_URL` | no | `null` | Mapswipe web app URL. |
+| `MAPSWIPE_CSRFTOKEN_KEY` | when `ENABLE_MAPSWIPE=true` | `null` | CSRF cookie name on the Mapswipe backend. |
+| `MAPSWIPE_FB_AUTH_URL` | when `ENABLE_MAPSWIPE=true` | `null` | Firebase auth endpoint. |
+| `MAPSWIPE_FB_USERNAME` | when `ENABLE_MAPSWIPE=true` | `null` | Firebase service-account username. |
+| `MAPSWIPE_FB_PASSWORD` | when `ENABLE_MAPSWIPE=true` | `null` | Firebase service-account password. |
+| `MAPSWIPE_TUTORIAL_ID` | no | `37` | Mapswipe tutorial ID injected into pushed projects. |
+| `MAPSWIPE_ORGANIZATION_ID` | no | `4` | Mapswipe organization ID. |
+| `MAPSWIPE_VERIFICATION_NUMBER` | no | `3` | Number of crowd verifications required per tile. |
+| `MAPSWIPE_POLL_INTERVAL` | no | `10` | Seconds between Mapswipe push-status polls. |
+| `MAPSWIPE_POLL_TIMEOUT` | no | `600` | Maximum total seconds to wait for a Mapswipe push. |
### Email
Only checked when `DEBUG=false`.
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `EMAIL_HOST` | no | `smtp.gmail.com` | SMTP host. |
-| `EMAIL_PORT` | no | `587` | SMTP port. |
-| `EMAIL_USE_TLS` | no | `true` | STARTTLS. |
-| `EMAIL_USE_SSL` | no | `false` | Implicit TLS (mutually exclusive with `EMAIL_USE_TLS`). |
-| `EMAIL_HOST_USER` | no | `""` | SMTP username. |
-| `EMAIL_HOST_PASSWORD` | no | `""` | SMTP password. |
-| `DEFAULT_FROM_EMAIL` | no | `no-reply@fair.hotosm.org` | `From:` header for outbound mail. |
+| Name | Required | Default | Description |
+| --------------------- | -------- | -------------------------- | ------------------------------------------------------- |
+| `EMAIL_HOST` | no | `smtp.gmail.com` | SMTP host. |
+| `EMAIL_PORT` | no | `587` | SMTP port. |
+| `EMAIL_USE_TLS` | no | `true` | STARTTLS. |
+| `EMAIL_USE_SSL` | no | `false` | Implicit TLS (mutually exclusive with `EMAIL_USE_TLS`). |
+| `EMAIL_HOST_USER` | no | `""` | SMTP username. |
+| `EMAIL_HOST_PASSWORD` | no | `""` | SMTP password. |
+| `DEFAULT_FROM_EMAIL` | no | `no-reply@fair.hotosm.org` | `From:` header for outbound mail. |
### Logging + pagination
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `LOG_PATH` | no | `./logs` | Directory for rotating log files. |
-| `DEFAULT_PAGINATION_SIZE` | no | `50` | DRF page size. |
-| `SESSION_COOKIE_AGE` | no | `3600` | Session cookie TTL in seconds. |
-| `CACHE_TIMEOUT_MINUTES` | no | `5` | Default LocMem cache TTL for non-STAC entries. |
-| `LOG_LINE_STREAM_TRUNCATE_VALUE` | no | `10` | Per-step log-line truncation factor for streamed pipeline logs. |
+| Name | Required | Default | Description |
+| -------------------------------- | -------- | -------- | --------------------------------------------------------------- |
+| `LOG_PATH` | no | `./logs` | Directory for rotating log files. |
+| `DEFAULT_PAGINATION_SIZE` | no | `50` | DRF page size. |
+| `SESSION_COOKIE_AGE` | no | `3600` | Session cookie TTL in seconds. |
+| `CACHE_TIMEOUT_MINUTES` | no | `5` | Default LocMem cache TTL for non-STAC entries. |
+| `LOG_LINE_STREAM_TRUNCATE_VALUE` | no | `10` | Per-step log-line truncation factor for streamed pipeline logs. |
### Operational tuning
All have safe defaults; only set to override.
-| Name | Required | Default | Description |
-|------|----------|---------|-------------|
-| `HEALTH_PROBE_TIMEOUT` | no | `2.0` | Seconds each HTTP probe waits before marking a dependency unreachable in `GET /api/v1/health/`. |
-| `PREDICTION_SYNC_INTERVAL` | no | `15` | Seconds between prediction-status poll re-enqueues. |
-| `TRAINING_SYNC_INTERVAL` | no | `30` | Seconds between training-status poll re-enqueues. |
-| `STAC_CACHE_TTL` | no | `300` | Seconds the LocMem cache holds a STAC item before re-fetching. Raise in prod for lower STAC load; lower in dev to see property changes faster. |
-| `STAC_BULK_FETCH_WORKERS` | no | `16` | Thread pool size for parallel STAC item fetches when `?expand=stac` is used on list endpoints. |
-| `PMTILES_MIN_ZOOM` | no | `10` | Min zoom passed to `tippecanoe` for PMTiles generation. |
-| `PMTILES_MAX_ZOOM` | no | `20` | Max zoom passed to `tippecanoe` for PMTiles generation. |
+| Name | Required | Default | Description |
+| -------------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
+| `HEALTH_PROBE_TIMEOUT` | no | `2.0` | Seconds each HTTP probe waits before marking a dependency unreachable in `GET /api/v1/health/`. |
+| `PREDICTION_SYNC_INTERVAL` | no | `15` | Seconds between prediction-status poll re-enqueues. |
+| `TRAINING_SYNC_INTERVAL` | no | `30` | Seconds between training-status poll re-enqueues. |
+| `STAC_CACHE_TTL` | no | `300` | Seconds the LocMem cache holds a STAC item before re-fetching. Raise in prod for lower STAC load; lower in dev to see property changes faster. |
+| `STAC_BULK_FETCH_WORKERS` | no | `16` | Thread pool size for parallel STAC item fetches when `?expand=stac` is used on list endpoints. |
+| `PMTILES_MIN_ZOOM` | no | `10` | Min zoom passed to `tippecanoe` for PMTiles generation. |
+| `PMTILES_MAX_ZOOM` | no | `20` | Max zoom passed to `tippecanoe` for PMTiles generation. |
## Worker process
diff --git a/backend/accounts/hanko_helpers.py b/backend/accounts/hanko_helpers.py
index a59620991..e904f35e6 100644
--- a/backend/accounts/hanko_helpers.py
+++ b/backend/accounts/hanko_helpers.py
@@ -69,25 +69,3 @@ def create_osm_user(
)
logger.info(f"Created OsmUser: osm_id={osm_id}, username={final_username}")
return user
-
-
-class HankoUserFilterMixin:
- """Mixin to filter queryset by authenticated user when ?mine=true is passed."""
-
- def get_queryset(self):
- queryset = super().get_queryset()
-
- mine_param = self.request.query_params.get("mine", "").lower()
- if mine_param == "true":
- if (
- hasattr(self.request, "user")
- and self.request.user
- and self.request.user.is_authenticated
- ):
- queryset = queryset.filter(user=self.request.user)
- logger.debug(f"Filtered by user {self.request.user.osm_id} (mine=true)")
- else:
- queryset = queryset.none()
- logger.debug("mine=true requested but user not authenticated, returning empty")
-
- return queryset
diff --git a/backend/accounts/tests.py b/backend/accounts/tests.py
deleted file mode 100644
index a39b155ac..000000000
--- a/backend/accounts/tests.py
+++ /dev/null
@@ -1 +0,0 @@
-# Create your tests here.
diff --git a/backend/accounts/views.py b/backend/accounts/views.py
index fcdfccf7b..2cd1cbbe0 100644
--- a/backend/accounts/views.py
+++ b/backend/accounts/views.py
@@ -232,10 +232,12 @@ def get(self, request):
f"'{osm_connection.osm_username}'. "
f"Please select 'No, I'm new' to create a new account."
)
+ # The callback is served by the API, not the frontend origin.
params = urlencode(
{
"onboarding": "fair",
"return_to": frontend_url,
+ "callback": f"{settings.API_BASE_URL.rstrip('/')}/auth/onboarding/",
"error": error_msg,
}
)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 169065586..2ce62b36a 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -31,8 +31,9 @@ def _str(value: Any | None) -> str | None:
LOG_PATH = str(settings.log_path)
os.makedirs(LOG_PATH, exist_ok=True)
-FRONTEND_URL = str(settings.frontend_url)
-API_BASE_URL = str(settings.api_base_url)
+# AnyHttpUrl appends a trailing slash to a bare host, which breaks f-string joins.
+FRONTEND_URL = str(settings.frontend_url).rstrip("/")
+API_BASE_URL = str(settings.api_base_url).rstrip("/")
HOSTNAME = settings.hostname
RAW_DATA_API_URL = str(settings.raw_data_api_url)
@@ -47,7 +48,7 @@ def _str(value: Any | None) -> str | None:
COOKIE_DOMAIN = settings.cookie_domain
COOKIE_SECURE = settings.cookie_secure if settings.cookie_secure is not None else not DEBUG
JWT_AUDIENCE = settings.jwt_audience
- LOGIN_URL = str(settings.login_url)
+ LOGIN_URL = str(settings.login_url).rstrip("/")
OSM_REDIRECT_URI = _str(settings.osm_login_redirect_uri)
LOGIN_INTERNAL_API_KEY = _secret(settings.login_internal_api_key) or ""
LOGIN_BACKEND_URL = _str(settings.login_backend_url) or LOGIN_URL
diff --git a/backend/modelregistry/knative-service.yaml b/backend/modelregistry/knative-service.yaml
index 0dd6802d3..1ede412ee 100644
--- a/backend/modelregistry/knative-service.yaml
+++ b/backend/modelregistry/knative-service.yaml
@@ -24,8 +24,9 @@ spec:
- secretRef:
name: s3-credentials
env:
+ # Match limits.cpu so onnxruntime does not oversubscribe the pod's cgroup CPU quota.
- name: FAIR_KNATIVE_ONNX_THREADS
- value: "0"
+ value: "2"
- name: FAIR_KNATIVE_CORS_ORIGINS
value: "*"
- name: FAIR_KNATIVE_CORS_METHODS
diff --git a/backend/modelregistry/serializers.py b/backend/modelregistry/serializers.py
index f0b99d53d..ccbcbc4a2 100644
--- a/backend/modelregistry/serializers.py
+++ b/backend/modelregistry/serializers.py
@@ -175,6 +175,22 @@ def validate_pinned_location(self, value: object) -> dict[str, Any]:
return location
+class ModelMetadataSerializer(serializers.Serializer):
+ """Edit a published model's STAC metadata. Any supplied field is written to the
+ item; the merged item is then re-validated against the fAIr schema before it is saved."""
+
+ title = serializers.CharField(required=False)
+ description = serializers.CharField(required=False)
+ fair_preview = serializers.JSONField(required=False)
+
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
+ if not attrs:
+ raise serializers.ValidationError(
+ "Provide at least one of: title, description, fair_preview."
+ )
+ return attrs
+
+
class TrainingRunSummarySerializer(serializers.Serializer):
"""ZenML run summary, populated from fair.zenml.runs.RunSummary."""
diff --git a/backend/modelregistry/views.py b/backend/modelregistry/views.py
index fad2b45d5..14574eaf1 100644
--- a/backend/modelregistry/views.py
+++ b/backend/modelregistry/views.py
@@ -47,6 +47,7 @@
BaseModelSerializer,
CategorySerializer,
LocalModelSerializer,
+ ModelMetadataSerializer,
ModelPinSerializer,
TrainingRunSummarySerializer,
)
@@ -84,6 +85,63 @@ def _apply_pin(model, collection: str, data: dict) -> None:
set_item_properties(collection, model.stac_item_id, properties)
+def _apply_metadata(collection: str, item_id: str, data: dict) -> None:
+ """Write title/description/fair:preview onto the item, re-validating the merged
+ item against the fAIr schema first so an edit can never leave it invalid."""
+ import pystac
+ from fair.stac.validators import validate_item
+
+ properties: dict = {}
+ if data.get("title"):
+ properties["title"] = data["title"]
+ if data.get("description"):
+ properties["description"] = data["description"]
+ if data.get("fair_preview") is not None:
+ properties["fair:preview"] = data["fair_preview"]
+
+ current = get_cached_item(collection, item_id)
+ merged = {**current, "properties": {**current.get("properties", {}), **properties}}
+ if errors := validate_item(pystac.Item.from_dict(merged)):
+ raise ValidationError({"stac": errors})
+ set_item_properties(collection, item_id, properties)
+
+
+class ModelMetadataMixin:
+ """Adds a `metadata` action that edits a published model's STAC title, description,
+ and fair:preview, with schema re-validation. Host viewset sets `stac_collection`."""
+
+ stac_collection: str = ""
+
+ @extend_schema(
+ request=ModelMetadataSerializer,
+ responses={200: OpenApiTypes.OBJECT},
+ examples=[
+ OpenApiExample(
+ "Edit title and preview",
+ value={
+ "title": "Buildings, Freetown",
+ "description": "Updated description.",
+ "fair_preview": {
+ "center": [-13.23723, 8.47532],
+ "zoom": {"recommended": 19},
+ "imagery": {"url": "https://tiles.example/{z}/{x}/{y}", "type": "tms"},
+ },
+ },
+ request_only=True,
+ )
+ ],
+ )
+ @action(detail=True, methods=["patch"], url_path="metadata")
+ def metadata(self, request, pk: int | None = None) -> Response:
+ model = self.get_object()
+ if not model.stac_item_id:
+ raise ValidationError("Model has no published STAC item to edit.")
+ serializer = ModelMetadataSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ _apply_metadata(self.stac_collection, model.stac_item_id, serializer.validated_data)
+ return Response(self.get_serializer(model).data)
+
+
def _wants_expand_stac(request) -> bool:
return request.query_params.get("expand") == "stac"
@@ -169,7 +227,7 @@ def _fetch_stac_item(url: str) -> dict:
),
runs=extend_schema(description="List ZenML pipeline runs that produced this model."),
)
-class LocalModelViewSet(StacExpandMixin, viewsets.ReadOnlyModelViewSet):
+class LocalModelViewSet(StacExpandMixin, ModelMetadataMixin, viewsets.ReadOnlyModelViewSet):
stac_collection = LOCAL_MODELS_COLLECTION
queryset = LocalModel.objects.all()
serializer_class = LocalModelSerializer
@@ -193,7 +251,7 @@ def get_queryset(self):
def get_permissions(self):
if self.action == "pin":
return [IsAuthenticated(), IsAdmin()]
- if self.action in {"publish", "unpublish"}:
+ if self.action in {"publish", "unpublish", "metadata"}:
return [IsAuthenticated(), IsOwnerOrAdmin()]
return super().get_permissions()
@@ -301,6 +359,7 @@ def runs(self, request, pk: int | None = None) -> Response:
)
class BaseModelViewSet(
StacExpandMixin,
+ ModelMetadataMixin,
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
@@ -327,7 +386,7 @@ def get_queryset(self):
return annotate_stars(qs, self.request, key_field="name")
def get_permissions(self):
- if self.action in {"create", "pin"}:
+ if self.action in {"create", "pin", "metadata"}:
return [IsAuthenticated(), IsAdmin()]
if self.action in {"list", "retrieve"}:
return [AllowAny()]
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 2b43bb01a..ccd25fec7 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -9,33 +9,33 @@ authors = [{ name = "HOTOSM", email = "sysadmin@hotosm.org" }]
maintainers = [{ name = "Kshitij Raj Sharma", email = "krschap@proton.me" }]
dependencies = [
- "adrf>=0.1.12",
- "boto3>=1.40",
- "dj-database-url>=2.3",
- "django>=6.0,<6.1",
- "django-cors-headers>=4.6",
- "django-filter>=24.3",
- "django-tasks>=0.12",
- "django-tasks-db>=0.5",
- "djangorestframework>=3.15",
- "djangorestframework-gis>=1.1",
- "drf-spectacular>=0.29",
- "fair-py-ops[k8s]==0.3.6",
- "geomltoolkits>=2.1.0",
- "geopandas>=1.0",
- "gpxpy>=1.6",
- "gunicorn>=23.0",
- "hotosm-auth[django]==0.2.12",
- "httpx>=0.28.1",
- "numpy>=2.0",
- "psycopg-pool>=3.3.1",
- "psycopg[binary]>=3.2",
- "pydantic>=2.10",
- "pydantic-settings>=2.7",
- "pyogrio>=0.10",
- "python-ulid>=3.1",
- "shapely>=2.0",
- "whitenoise>=6.6",
+ "adrf>=0.1.12",
+ "boto3>=1.40",
+ "dj-database-url>=2.3",
+ "django>=6.0,<6.1",
+ "django-cors-headers>=4.6",
+ "django-filter>=24.3",
+ "django-tasks>=0.12",
+ "django-tasks-db>=0.5",
+ "djangorestframework>=3.15",
+ "djangorestframework-gis>=1.1",
+ "drf-spectacular>=0.29",
+ "fair-py-ops[k8s]==0.3.10",
+ "geomltoolkits>=2.1.0",
+ "geopandas>=1.0",
+ "gpxpy>=1.6",
+ "gunicorn>=23.0",
+ "hotosm-auth[django]==0.2.12",
+ "httpx>=0.28.1",
+ "numpy>=2.0",
+ "psycopg-pool>=3.3.1",
+ "psycopg[binary]>=3.2",
+ "pydantic>=2.10",
+ "pydantic-settings>=2.7",
+ "pyogrio>=0.10",
+ "python-ulid>=3.1",
+ "shapely>=2.0",
+ "whitenoise>=6.6",
]
[dependency-groups]
@@ -51,12 +51,7 @@ override-dependencies = ["pyjwt[crypto]>=2.8.0,<3"]
[tool.ruff]
target-version = "py312"
line-length = 100
-extend-exclude = [
- "**/migrations/**",
- "shared/integrations/mapswipe.py",
- "tests/**",
- "login/**",
-]
+extend-exclude = ["**/migrations/**", "shared/integrations/mapswipe.py", "tests/**"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
@@ -77,12 +72,7 @@ deprecated = "ignore"
missing-argument = "ignore"
[tool.ty.src]
-exclude = [
- "**/migrations/**",
- "**/tests/**",
- "shared/integrations/mapswipe.py",
- "login/**",
-]
+exclude = ["**/migrations/**", "**/tests/**", "shared/integrations/mapswipe.py"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
@@ -91,17 +81,18 @@ addopts = "-ra --strict-markers"
[tool.coverage.run]
source = [
- "datasets",
- "feedback",
- "login",
- "modelregistry",
- "notifications",
- "prediction",
- "shared",
- "system",
- "training",
- "workspace",
- "config",
+ "accounts",
+ "datasets",
+ "modelregistry",
+ "trainings",
+ "predictions",
+ "feedback",
+ "notifications",
+ "stars",
+ "workspace",
+ "system",
+ "shared",
+ "config",
]
omit = ["**/migrations/**", "**/tests/**"]
diff --git a/backend/shared/integrations/stac.py b/backend/shared/integrations/stac.py
index 33a5e1def..2fb5310ca 100644
--- a/backend/shared/integrations/stac.py
+++ b/backend/shared/integrations/stac.py
@@ -31,7 +31,6 @@
"FAIR_SOURCE_IMAGERY_PROPERTY",
"LOCAL_MODELS_COLLECTION",
"bulk_get_cached_items",
- "deprecate_item",
"get_active_local_model_item",
"get_base_model",
"get_cached_item",
@@ -94,11 +93,6 @@ def item_exists(collection_id: str, item_id: str) -> bool:
return _backend().item_exists(collection_id, item_id)
-def deprecate_item(collection_id: str, item_id: str) -> pystac.Item:
- invalidate_stac_cache(collection_id, item_id)
- return _backend().deprecate_item(collection_id, item_id)
-
-
def _public_links(links: list[dict]) -> list[dict]:
# Internal STAC-host links are dead publicly; consumers navigate via this backend, so drop them.
internal = settings.FAIR_STAC_API_URL
diff --git a/backend/shared/storage.py b/backend/shared/storage.py
index 77c9498a2..9263ecb88 100644
--- a/backend/shared/storage.py
+++ b/backend/shared/storage.py
@@ -158,7 +158,7 @@ def local_model_metrics_key(cls, item_id: str) -> str:
class BackendLocalModelPaths(LocalModelStoragePaths):
- ROOT = f"{_folder()}{StoragePaths.LOCAL_MODELS_ROOT}"
+ ROOT = StoragePaths.LOCAL_MODELS_ROOT
CHECKPOINT_SUBDIR = StoragePaths.LOCAL_MODELS_CHECKPOINT_SUBDIR
MODEL_SUBDIR = StoragePaths.LOCAL_MODELS_MODEL_SUBDIR
METRICS_SUBDIR = StoragePaths.LOCAL_MODELS_METRICS_SUBDIR
diff --git a/backend/tests/test_base_model_endpoints.py b/backend/tests/test_base_model_endpoints.py
index 08ab27017..c42072510 100644
--- a/backend/tests/test_base_model_endpoints.py
+++ b/backend/tests/test_base_model_endpoints.py
@@ -430,7 +430,69 @@ def test_mirror_and_relink_rewrites_only_downloadable_assets(settings) -> None:
from shared.integrations.stac import mirror_and_relink_assets
mirror_and_relink_assets("base-models", "x")
- assert item.assets["model"].href == "https://dev.example/api/v1/stac-assets/base-models/x/model/"
+ assert (
+ item.assets["model"].href == "https://dev.example/api/v1/stac-assets/base-models/x/model/"
+ )
assert item.assets["readme"].href == "https://github.com/readme"
mock_stream.assert_called_once()
backend.publish_item.assert_called_once()
+
+
+def _stac_item_dict(item_id: str = "meta-model") -> dict:
+ from datetime import UTC, datetime
+
+ item = pystac.Item(
+ id=item_id,
+ geometry={"type": "Point", "coordinates": [0, 0]},
+ bbox=[0, 0, 0, 0],
+ datetime=datetime.now(UTC),
+ properties={"title": "Old title", "description": "Old", "mlm:name": item_id},
+ )
+ return item.to_dict()
+
+
+@patch("modelregistry.views.set_item_properties")
+@patch("modelregistry.views.get_cached_item", return_value=_stac_item_dict())
+@patch("fair.stac.validators.validate_item", return_value=[])
+def test_base_model_metadata_edits_title_description_and_preview(
+ mock_validate, mock_get, mock_set, admin: OsmUser
+) -> None:
+ model = BaseModel.objects.create(name="meta-model", user=admin, stac_item_id="meta-model")
+ preview = {
+ "center": [1.0, 2.0],
+ "zoom": {"recommended": 19},
+ "imagery": {"url": "https://t/{z}/{x}/{y}"},
+ }
+ resp = _client(admin).patch(
+ f"/api/v1/base-models/{model.id}/metadata/",
+ {"title": "New title", "description": "New description", "fair_preview": preview},
+ format="json",
+ )
+ assert resp.status_code == 200
+ props = mock_set.call_args.args[2]
+ assert props["title"] == "New title"
+ assert props["description"] == "New description"
+ assert props["fair:preview"] == preview
+ mock_validate.assert_called_once()
+
+
+@patch("modelregistry.views.set_item_properties")
+@patch("modelregistry.views.get_cached_item", return_value=_stac_item_dict())
+@patch("fair.stac.validators.validate_item", return_value=["mlm:tasks is a required property"])
+def test_base_model_metadata_rejects_invalid_edit(
+ mock_validate, mock_get, mock_set, admin: OsmUser
+) -> None:
+ model = BaseModel.objects.create(name="meta-model", user=admin, stac_item_id="meta-model")
+ resp = _client(admin).patch(
+ f"/api/v1/base-models/{model.id}/metadata/", {"title": "New"}, format="json"
+ )
+ assert resp.status_code == 400
+ mock_set.assert_not_called()
+
+
+def test_base_model_metadata_requires_admin(user: OsmUser) -> None:
+ model = BaseModel.objects.create(name="meta-model", user=user, stac_item_id="meta-model")
+ resp = _client(user).patch(
+ f"/api/v1/base-models/{model.id}/metadata/", {"title": "New"}, format="json"
+ )
+ assert resp.status_code == 403
diff --git a/backend/uv.lock b/backend/uv.lock
index 68fe6ede2..c173df94e 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -1053,7 +1053,7 @@ requires-dist = [
{ name = "djangorestframework", specifier = ">=3.15" },
{ name = "djangorestframework-gis", specifier = ">=1.1" },
{ name = "drf-spectacular", specifier = ">=0.29" },
- { name = "fair-py-ops", extras = ["k8s"], specifier = "==0.3.6" },
+ { name = "fair-py-ops", extras = ["k8s"], specifier = "==0.3.10" },
{ name = "geomltoolkits", specifier = ">=2.1.0" },
{ name = "geopandas", specifier = ">=1.0" },
{ name = "gpxpy", specifier = ">=1.6" },
@@ -1087,7 +1087,7 @@ test = [
[[package]]
name = "fair-py-ops"
-version = "0.3.6"
+version = "0.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -1099,9 +1099,9 @@ dependencies = [
{ name = "universal-pathlib" },
{ name = "zenml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/50/2f/dc1e4d60ce56215eb506b8270774559cdf3b04914498514fb962ed28fbe9/fair_py_ops-0.3.6.tar.gz", hash = "sha256:1dd4a743d5dd1d1514a0c3d33103c399039c30eeea2f013c72f92eacff4c5e4b", size = 3942090, upload-time = "2026-08-20T21:15:08.289Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/f6/02b9d9249f72720dd8238087259bd910501319bc778c1df045801c02d23b/fair_py_ops-0.3.10.tar.gz", hash = "sha256:d444aa091b93a8e07875aba9528655c210ce2c93cc20bec7f148c3d31f0c7da5", size = 3932508, upload-time = "2026-09-06T14:07:14.657Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/55/f5ee696ab9fb0fa375c5dae80e83802ae67ef6af11d529a35918bcc3aacf/fair_py_ops-0.3.6-py3-none-any.whl", hash = "sha256:5983d43b3f36b4bb943ed545e4512ef160d4cd9d4eee656010a2200348a98460", size = 4077447, upload-time = "2026-08-20T21:15:06.69Z" },
+ { url = "https://files.pythonhosted.org/packages/81/b6/20412a26611c1f7abb63f6e6a5bcba22e09ec3e7c34b0852afc24c7e93b4/fair_py_ops-0.3.10-py3-none-any.whl", hash = "sha256:2d787ff45ce1e9d410fa770a6bc97cf32ca9649763bfc5f67c08ca3815f35fa5", size = 4078814, upload-time = "2026-09-06T14:07:12.964Z" },
]
[package.optional-dependencies]
diff --git a/chart/Chart.yaml b/chart/Chart.yaml
index b575c0ff8..39c0dda7d 100644
--- a/chart/Chart.yaml
+++ b/chart/Chart.yaml
@@ -2,5 +2,5 @@ apiVersion: v2
name: fair
description: AI Assisted Mapping Tool
type: application
-version: 2.2.21
-appVersion: "2.2.21"
+version: 2.2.23
+appVersion: "2.2.23"
diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl
index e09271514..3bfb17d0d 100644
--- a/chart/templates/_helpers.tpl
+++ b/chart/templates/_helpers.tpl
@@ -109,3 +109,14 @@ that would otherwise come from `externalDatabase`.
name: {{ include "fair.postgresName" . }}
key: DATABASE_URL
{{- end }}
+
+{{/* Argo CD sync wave; ignored by Helm. */}}
+{{- define "fair.syncWave" -}}
+argocd.argoproj.io/sync-wave: {{ . | quote }}
+{{- end }}
+
+{{/* Migration Job name with a revision-preserving suffix. */}}
+{{- define "fair.migrateJobName" -}}
+{{- $suffix := printf "-migrate-%d" (.Release.Revision | int) -}}
+{{- printf "%s%s" (include "fair.backend.fullname" . | trunc (int (sub 63 (len $suffix))) | trimSuffix "-") $suffix -}}
+{{- end }}
diff --git a/chart/templates/backend/configmap.yaml b/chart/templates/backend/configmap.yaml
index 580124919..3c7e55e2c 100644
--- a/chart/templates/backend/configmap.yaml
+++ b/chart/templates/backend/configmap.yaml
@@ -5,6 +5,8 @@ metadata:
labels:
{{- include "fair.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
+ annotations:
+ {{- include "fair.syncWave" "-10" | nindent 4 }}
data:
{{- if eq (.Values.frontend.mode | default "bundleWithBackend") "bundleWithBackend" }}
# Serve the SPA baked into the fair/api image via Django/WhiteNoise.
diff --git a/chart/templates/backend/migrate-job.yaml b/chart/templates/backend/migrate-job.yaml
index 9fcf99f98..2ea2f3c6b 100644
--- a/chart/templates/backend/migrate-job.yaml
+++ b/chart/templates/backend/migrate-job.yaml
@@ -2,14 +2,16 @@
apiVersion: batch/v1
kind: Job
metadata:
- name: {{ include "fair.backend.fullname" . }}-migrate
+ # Job specs are immutable, so use a new name for each Helm revision.
+ name: {{ include "fair.migrateJobName" . }}
labels:
{{- include "fair.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
annotations:
- "helm.sh/hook": post-install,post-upgrade
- "helm.sh/hook-weight": "-5"
- "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
+ # Argo CD: dependencies (-10), migration (-5), workloads (0).
+ argocd.argoproj.io/hook: Sync
+ argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
+ {{- include "fair.syncWave" "-5" | nindent 4 }}
spec:
template:
metadata:
diff --git a/chart/templates/backend/postgres.yaml b/chart/templates/backend/postgres.yaml
index e3950688f..4eb21eede 100644
--- a/chart/templates/backend/postgres.yaml
+++ b/chart/templates/backend/postgres.yaml
@@ -6,6 +6,8 @@ metadata:
labels:
{{- include "fair.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
+ annotations:
+ {{- include "fair.syncWave" "-10" | nindent 4 }}
type: Opaque
stringData:
POSTGRES_USER: {{ .Values.postgres.auth.username | quote }}
@@ -20,6 +22,8 @@ metadata:
labels:
{{- include "fair.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
+ annotations:
+ {{- include "fair.syncWave" "-10" | nindent 4 }}
spec:
type: ClusterIP
ports:
@@ -38,6 +42,8 @@ metadata:
labels:
{{- include "fair.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
+ annotations:
+ {{- include "fair.syncWave" "-10" | nindent 4 }}
spec:
serviceName: {{ include "fair.postgresName" . }}
replicas: 1
diff --git a/chart/templates/backend/secret.yaml b/chart/templates/backend/secret.yaml
index e7baee47d..83ba3ccfc 100644
--- a/chart/templates/backend/secret.yaml
+++ b/chart/templates/backend/secret.yaml
@@ -6,6 +6,8 @@ metadata:
labels:
{{- include "fair.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
+ annotations:
+ {{- include "fair.syncWave" "-10" | nindent 4 }}
type: Opaque
data:
password: {{ .Values.externalDatabase.password | b64enc | quote }}
diff --git a/chart/templates/frontend/cloudfront-deploy-job.yaml b/chart/templates/frontend/cloudfront-deploy-job.yaml
index c34bacd20..10a6c52c1 100644
--- a/chart/templates/frontend/cloudfront-deploy-job.yaml
+++ b/chart/templates/frontend/cloudfront-deploy-job.yaml
@@ -9,8 +9,7 @@ metadata:
{{- include "fair.labels" . | nindent 4 }}
app.kubernetes.io/component: cloudfront-deploy
annotations:
- # Run after the backend rollout (migrate hook is weight -5) so the API is
- # live before the new frontend starts serving traffic.
+ # Publish after the backend rollout.
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml
index 41a3ae6d9..018230909 100644
--- a/chart/templates/serviceaccount.yaml
+++ b/chart/templates/serviceaccount.yaml
@@ -6,8 +6,8 @@ metadata:
labels:
{{- include "fair.labels" . | nindent 4 }}
{{- $irsa := and (eq (.Values.frontend.mode | default "bundleWithBackend") "cloudfront") .Values.frontend.cloudfront.roleArn }}
- {{- if or .Values.serviceAccount.annotations $irsa }}
annotations:
+ {{- include "fair.syncWave" "-10" | nindent 4 }}
{{- if $irsa }}
# IRSA: lets the CloudFront deploy Job assume the IAM role without static keys.
eks.amazonaws.com/role-arn: {{ .Values.frontend.cloudfront.roleArn | quote }}
@@ -15,5 +15,4 @@ metadata:
{{- with .Values.serviceAccount.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
- {{- end }}
{{- end }}
diff --git a/chart/values.yaml b/chart/values.yaml
index 607ac4721..978a471cb 100644
--- a/chart/values.yaml
+++ b/chart/values.yaml
@@ -90,7 +90,7 @@ backend:
podSecurityContext: {}
securityContext: {}
- # -- Run Django migrations as a pre-install/upgrade hook
+ # -- Run Django migrations during install and upgrade
migrate:
enabled: true
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 04fa36584..34fbd3568 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -15,6 +15,8 @@ services:
ports:
- "80:80"
- "443:443"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
configs:
- source: caddyfile
target: /etc/caddy/Caddyfile
@@ -41,7 +43,13 @@ services:
api:
restart: unless-stopped
- entrypoint: ["sh", "-c", 'zenml init && zenml stack set fair-compose && python manage.py collectstatic --noinput && exec "$$@"', "--"]
+ entrypoint:
+ [
+ "sh",
+ "-c",
+ 'zenml init && zenml stack set fair-compose && python manage.py collectstatic --noinput && exec "$$@"',
+ "--",
+ ]
command:
- gunicorn
- --bind=0.0.0.0:8000
@@ -90,6 +98,9 @@ configs:
content: |
{
email ${CADDY_ACME_EMAIL:-sysadmin@hotosm.org}
+ on_demand_tls {
+ ask http://127.0.0.1:5555/check
+ }
}
${PUBLIC_DOMAIN:-dev.ai.hotosm.org}, fair-dev.hotosm.org {
handle_path /stac/* {
@@ -100,6 +111,17 @@ configs:
stac.${PUBLIC_DOMAIN:-dev.ai.hotosm.org} {
reverse_proxy stac:8080
}
+ # Per-model knative predict endpoints. Kourier is HTTP-only (NodePort), so
+ # Caddy terminates TLS per host on demand and proxies to the node ingress.
+ :5555 {
+ respond /check 200
+ }
+ *.predict.${PUBLIC_DOMAIN:-dev.ai.hotosm.org} {
+ tls {
+ on_demand
+ }
+ reverse_proxy ${KNATIVE_KOURIER_ADDR:-host.docker.internal:31751}
+ }
# mlflow.${PUBLIC_DOMAIN:-dev.ai.hotosm.org} { reverse_proxy mlflow:5000 }
# zenml.${PUBLIC_DOMAIN:-dev.ai.hotosm.org} { reverse_proxy zenml:8080 }
# minio.${PUBLIC_DOMAIN:-dev.ai.hotosm.org} { reverse_proxy minio:9000 }
diff --git a/docker-compose.hotreload.yml b/docker-compose.hotreload.yml
index dd79b5d8c..d74f7fd71 100644
--- a/docker-compose.hotreload.yml
+++ b/docker-compose.hotreload.yml
@@ -1,9 +1,8 @@
-# Opt-in local hot reload: bind-mounts ./backend into the api and worker
+# Opt-in local hot reload: bind-mounts ./backend into the api and worker
# TODO : add frontend support
# Enable by appending this file to COMPOSE_FILE, e.g.:
# COMPOSE_FILE=docker-compose.yml:docker-compose.hotreload.yml docker compose up
-
services:
api:
volumes:
diff --git a/docker-compose.yml b/docker-compose.yml
index 1704ecb65..ba11f9c2a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -114,7 +114,13 @@ services:
ports:
- "${STAC_PORT:-8082}:8080"
healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/_mgmt/ping')"]
+ test:
+ [
+ "CMD",
+ "python",
+ "-c",
+ "import urllib.request; urllib.request.urlopen('http://localhost:8080/_mgmt/ping')",
+ ]
interval: 5s
timeout: 3s
retries: 30
diff --git a/docs/About.md b/docs/About.md
index 7046bf58c..b1d6726cc 100644
--- a/docs/About.md
+++ b/docs/About.md
@@ -1,6 +1,6 @@
# fAIr
-## :open_book: History
+## :open_book: History
We recognized the Open Cities Challenge for building segmentation mid-2020, and then around the end of 2020, HOT conducted research in collaboration with the Netherland Red Cross. Last year, HOT contributed to an academic research project investigating the capability of UAV imagery to be used for AI-assisted mapping on refugees camps in Africa, which proved that the use of localised AI models produces higher prediction accuracy in comparison to wide trained models.
@@ -10,8 +10,9 @@ In March 2022, we participated in an AI for Social good seminar in Frankfurt, Ge

## Glossary
+
fAIr is the product name. How come?:
-
+
f: for freedom and free and open-source software
@@ -24,7 +25,9 @@ AI models: AI is wide term and it includes lots of approaches and techniques. In
+
## What , How and for Whom?
+
Unlike other AI data producers, fAIr is an intuitive, fair and open-source AI-assisted mapping tool where AI models are created and trained by the people living and working in the local communities. By working with the local communities (and getting constant feedback on the models), we strive to eliminate model biases as we ensure the models are relevant to the communities where the maps are being created to improve the conditions of the people living there.

diff --git a/docs/Code-of-Conduct.md b/docs/Code-of-Conduct.md
index ba6a53a4a..1ef2bfdd7 100644
--- a/docs/Code-of-Conduct.md
+++ b/docs/Code-of-Conduct.md
@@ -9,38 +9,36 @@ The HOT community principles are:
- **Be friendly and patient.** Be generous and kind in both giving and accepting critique. Critique is a natural and important part of our culture. Good critiques are kind, respectful, clear, and constructive, focused on goals and requirements rather than personal preferences. You are expected to give and receive criticism with grace. Be considerate in speech and actions, and actively seek to acknowledge and respect the boundaries of fellow attendees.
- **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. Some examples of behavior that contributes to creating a positive environment include:
+ - Using welcoming and inclusive language.
- - Using welcoming and inclusive language.
+ - Being respectful of differing viewpoints and experiences.
- - Being respectful of differing viewpoints and experiences.
+ - Gracefully accepting constructive criticism.
- - Gracefully accepting constructive criticism.
+ - Showing empathy towards other community members.
- - Showing empathy towards other community members.
-
- - Placing collective interest before your own interest.
+ - Placing collective interest before your own interest.
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the HOT community should be respectful when dealing with other members as well as with people outside the HOT community.
- **Be careful in your word choice.** We are a global community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
+ - Violent threats or language directed against another person.
- - Violent threats or language directed against another person.
-
- - Discriminatory jokes and language.
+ - Discriminatory jokes and language.
- - Posting sexually explicit or violent material.
+ - Posting sexually explicit or violent material.
- - Posting (or threatening to post) other people's personally identifying information ("doxing").
+ - Posting (or threatening to post) other people's personally identifying information ("doxing").
- - Personal insults, especially those using racist or sexist terms.
+ - Personal insults, especially those using racist or sexist terms.
- - Unwelcome sexual attention.
+ - Unwelcome sexual attention.
- - Advocating for, or encouraging, any of the above behavior.
+ - Advocating for, or encouraging, any of the above behavior.
- - Repeated harassment of others. In general, if someone asks you to stop, then stop.
+ - Repeated harassment of others. In general, if someone asks you to stop, then stop.
- **Assume all communications are positive.** Always remain polite, and assume good faith. It is surprisingly easy to misunderstand each other, be it online or in person, particularly in such a culturally diverse setting as ours. Misunderstandings are particularly easy to arise when we are in a rush, or otherwise distracted. Please ask clarifying questions before assuming that a communication was inappropriate.
diff --git a/docs/Docker-installation.md b/docs/Docker-installation.md
index 98644f13e..8896d044b 100644
--- a/docs/Docker-installation.md
+++ b/docs/Docker-installation.md
@@ -37,18 +37,18 @@ it at a stack on other ports with `--api`, `--stac`, and `--minio`.
## Services
-| Service | URL | Credentials |
-| --- | --- | --- |
-| fAIr frontend and API | | Bearer `dev-token` |
-| Swagger UI | | |
-| ReDoc | | |
-| OpenAPI schema | | |
-| Health probes | | |
-| ZenML | | `default`, empty password |
-| STAC | | |
-| MLflow | | |
-| MinIO console | | `minioadmin` / `minioadmin` |
-| PostgreSQL | `localhost:5434` | `admin` / `password` |
+| Service | URL | Credentials |
+| --------------------- | -------------------------------------- | --------------------------- |
+| fAIr frontend and API | | Bearer `dev-token` |
+| Swagger UI | | |
+| ReDoc | | |
+| OpenAPI schema | | |
+| Health probes | | |
+| ZenML | | `default`, empty password |
+| STAC | | |
+| MLflow | | |
+| MinIO console | | `minioadmin` / `minioadmin` |
+| PostgreSQL | `localhost:5434` | `admin` / `password` |
All v1 routes are under `/api/v1/`. Versioning uses DRF `NamespaceVersioning`,
so `request.version` is set per request and `/api/v2/` is one URL line away when
diff --git a/docs/FAQ.md b/docs/FAQ.md
index e4bfea66d..4917f8840 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -1,6 +1,7 @@
# Frequently Asked Questions (FAQs)
## Users
+
**Q : What is fAIr?**
A : fAIr is an open-source toolkit developed by the Humanitarian OpenStreetMap Team (HOT) that enables the integration of artificial intelligence (AI) into humanitarian mapping workflows. It provides AI models and tools to automate mapping tasks, improving efficiency and accuracy.
diff --git a/docs/Home.md b/docs/Home.md
index 72b0ce628..7e7f8f974 100644
--- a/docs/Home.md
+++ b/docs/Home.md
@@ -3,12 +3,13 @@
## _**What are the challenges that mappers experience while utilizing AI data on OpenStreetMap?**_
- ### Overview
+
fAIr does mapping in the same way as human mappers using HOTs Tasking Manager. It looks at UAV imagery and produces map data that can be added to the OSM. Tests show a 100% speedup compared to manual mapping. It uses Artificial Intelligence (AI) to accomplish this.
fAIr is developed by the Humanitarian OpenStreetMap Team and all the software is free and open source.
Before fAIr is used it needs to be fine-tuned by training on high quality map data for a small representative part of the geographical region where it is to be used
-
+
- ### Quality of AI Data
One of the primary challenges faced by mappers when using AI data on OpenStreetMap is the quality of the data. The accuracy and completeness of AI data depends on the quality of the training data used to train the AI model. If the training data is biased or incomplete, the AI model will produce inaccurate results and this has happened in some cases especially with roads. Mappers must therefore carefully assess the quality of the AI data before adding it to OSM.
- ### Data integration process.
@@ -17,9 +18,9 @@
The use of AI technology in mapping requires technical expertise, which can be a significant challenge for mappers who may not have the necessary skills. For instance, beginer Mappers can not be recommended or trained to use these AI tools as they may mess the entire area. Therefore frequent trainings are needed for mappers to be equipped with adquet skills of handling such tasks.
- ### Imagery offsets
The AI technologies for example AI data from Microsoft was and is generated using Bing Imagery which in several cases is older compared to the recent Imageries like Maxar Premium. The imposes a big challenge in aligning the AI data with the latest imageries.
-
-
+
+
### Here the HOT’s open AI-assisted mapping service: fAIr comes to rescue .
-The fAIr tool is also an open-source mapping tool with AI assistance, and the AI models it uses are developed and trained by people who reside in and work in nearby towns. To know more about fAIr check the **About** page.
-
+
+The fAIr tool is also an open-source mapping tool with AI assistance, and the AI models it uses are developed and trained by people who reside in and work in nearby towns. To know more about fAIr check the **About** page.
diff --git a/docs/Infra.md b/docs/Infra.md
index 57ef766e4..48e44f275 100644
--- a/docs/Infra.md
+++ b/docs/Infra.md
@@ -4,6 +4,7 @@ Our standard deployment process for other apps is
[here](https://docs.hotosm.org/devops/deployment-process)
fAIr differs slightly, because we have:
+
- Versioning of both software, as well as AI models.
- A dedicated dev instance EC2 for easier development with all components.
@@ -11,6 +12,7 @@ Currently model development happens in the `fAIr-models` repo, but this
might eventually move to the `fAIr` monorepo.
The model flow works like this:
+
- Each model dir has a `stac-item.json`. These point at the moving
`dev-inference` image tag, and only seed a STAC the first time it starts up
(on dev, or a brand new prod).
@@ -27,6 +29,7 @@ The model flow works like this:
> [!NOTE]
> The Environment
+>
> - Single EC2, lightweight k3s cluster.
> - Manually updated / synced with dev.
> - Model registration in STAC etc is all manual.
@@ -45,6 +48,7 @@ The model flow works like this:
> [!NOTE]
> The Environment
+>
> - Runs all the same components as production, but
> start up via PR from `staging` --> `main`.
> - The components run inside the `fair-staging`
@@ -70,6 +74,7 @@ The model flow works like this:
> [!NOTE]
> The Environment
+>
> - Runs through tagged releases on Github, where ArgoCD
> picks up the latest helm chart tag and deploys.
diff --git a/docs/Release.md b/docs/Release.md
index bd080f427..13284dfaf 100644
--- a/docs/Release.md
+++ b/docs/Release.md
@@ -1,4 +1,4 @@
-We use [commitizen](https://pypi.org/project/commitizen/) to manage our release version
+We use [commitizen](https://pypi.org/project/commitizen/) to manage our release version
- Install commitizen
```bash
diff --git a/docs/User-Manual-for-fAIr.md b/docs/User-Manual-for-fAIr.md
index eb6a982d4..e3b709df5 100644
--- a/docs/User-Manual-for-fAIr.md
+++ b/docs/User-Manual-for-fAIr.md
@@ -1,4 +1,5 @@
# User Manual for fAIr
+
This manual is a step by step guide for the community project managers on how to get started with the fAIr.
@@ -8,9 +9,10 @@ This manual is a step by step guide for the community project managers on how to
- [Steps to start access your project and Start mapping](#steps-to-start-access-your-project-and-start-mapping)
- [Steps to make changes to the prediction of a particular model(even if you are not a project manager)](#steps-to-make-changes-to-the-prediction-of-a-particular-model)
- [Help and Support](#help-and-support)
-- [Thank you note](#thank-you)
+- [Thank you note](#thank-you)
## Prerequisites
+
- Stable Internet connection
- Knowledge on mapping . If you are new to mapping we suggest you to read [this](https://tasks.hotosm.org/learn/map) .
- Very basic knowledge on training AI Datasets and Models.
@@ -18,120 +20,73 @@ This manual is a step by step guide for the community project managers on how to
## Video Tutorial
-
https://github.com/hotosm/fAIr/assets/97789856/47121891-b21a-43c0-bb60-1e03f5222c10
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/d7d86a6f-e492-4169-8443-d9924cb10e54
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/f68def4f-6b0b-4870-801c-0fac16713249
-
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/3da0771d-0346-4042-9049-4f321c27ba8d
-
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/1e2efaf0-f0c2-4331-a290-566434db5db3
-
-
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/a667433b-5d50-4160-b65c-192aaeae79af
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/2dcae84f-b873-42b4-a9bf-93f589b563f4
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/7e29d4ec-65a1-4aef-ab67-42efb94eba6d
-
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/fe3f55e6-d175-423f-ba71-10283671e0cb
-
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/47d0a5fe-c606-4168-b401-4b34d70a3a0e
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/43327f2d-e807-46e5-a1db-a8f58091eebd
-
-
https://github.com/hotosm/fAIr/assets/97789856/1113b858-67a1-4ec9-8895-a955e7cd9063
-
-
https://github.com/hotosm/fAIr/assets/97789856/35ea2f68-92ef-4d8d-bfaf-fb05ec507d5a
-
-
-
https://github.com/hotosm/fAIr/assets/97789856/f4eae99f-e9a2-4424-806f-fdfce80e66dd
-
-
## Steps to create a project in fAIr
1. Go to [fAIr](https://fair-dev.hotosm.org/) .
2. First Login & Click on the button Start Creating Dataset.To create a new training dataset, start by clicking on the 'Start Creating Dataset' button.
-
+
3. Click on the button Create New.After clicking on 'Start Creating Dataset', click on the 'Create New' button to create a new dataset.
-
+
> A dataset would be a list of area of interests (AIOs) and its labels. OpenStreetMap data can be downlaoded automatically and used as initial
> labels. It is our responsibility as model creators to make sure labels align with the feature before proceeding to training phase.
4. Click on the input field , Give your Dataset Name.To name your dataset, click on the input field and type in your desired name & click on the 'Create Training Dataset'.
-
+
5. Find Image to Train.After creating Dataset You will see following screen , Now Open Open Aerial Map in New Tab , https://openaerialmap.org/.
-
+
-6. Find Drone Image and Copy TMS URL.Look for your area and find good Drone Image for training . After finding , Click on Copy Image URL TMS.
-
+6. Find Drone Image and Copy TMS URL.Look for your area and find good Drone Image for training . After finding , Click on Copy Image URL TMS.
+
7. Paste TMS To Open Aerial Imagery Tab.Paste your TMS URL that you have copied from Open Aerial Map Drone Image.
-
+
8. Zoom to Layer and Visualize Image.Click on Zoom to layer next to your Image name in OAM Block Top Right side of screen.
-
+
-9. Create Area of Interest for Training.Click on top left map buttons below zoom , To create AOI , AOI will be used to create labels.
+9. Create Area of Interest for Training.Click on top left map buttons below zoom , To create AOI , AOI will be used to create labels.
-> Labels are Buildings that will be acting as input for model inside AOI.
+> Labels are Buildings that will be acting as input for model inside AOI.
10. Fetch Exisiting OSM Buildings in your Area of Interest.Click on Fetch OSM Data button next to OSM Logo Inside List of Area of Interest on Right side.
-
+
11. Visualize each Buildings to check their accuracy.Zoom to Level 20 in Map to see OSM Buildings that you have just fetched.
-
-
+
12. Correct your labels.For training it is crucial that buildings are aligned exactly on Drone image, More good your input is more good your output will be. If you want to edit it Click on OSM Logo and Fix buildings / labels in your AOI.
@@ -139,57 +94,44 @@ https://github.com/hotosm/fAIr/assets/97789856/f4eae99f-e9a2-4424-806f-fdfce80e6
-
13. Upload your fixes.You should see your AOI in ID , Fix your labels and Upload your changes to OSM.
-
-
-
+
14. Come Back to fAIr , Fetch OSM Labels.After you do your changes on OSM , Comeback to fAIr and fetch new labels, Give it a few min to update our database ( Click on button next to OSM Logo on AOI to fetch).
-
-
+
15. Create Model for you Dataset.Click on View Models on your dataset page , You will see this page. Click on Create New.
-
+
16. Provide Model Metadata.Give your model name , Choose Your Dataset from Dropdown , Select BaseModel : RAMP is default for now , Click on Create AI Model.
-
-
+
17. Create Training for Your Model.After creating Model for your dataset you will see following page. From here You can submit Trainings for your model. Give your epochs , Batch size and Zoom level for Training .
-
-
+
18. Submit Your Training.Click on Submit Training Request and slide down, you will see your training listed there , You can check its status by clicking on info, Based on your dataset , AOI , your parameter model training may take time you can check progress on status. SUBMITTED , RUNNING , FINISHED .
- You can see your model accuracy and use it after it is finished. If it fails you can check the reason for it and adapt accordingly.
-
-
+ You can see your model accuracy and use it after it is finished. If it fails you can check the reason for it and adapt accordingly.
+
19. Check info of your Trainings.Click on i icon button next to your training to visualize current terminal and process of your training , It will display accuracy validation graph after training is finished.
-
-
+
20. Finished Training.You can visualize your trainings accuracy and it's graph after it is finished like this.
-
-
-
-21. Publish Your Training.Once you are statisfied accuracy and want to visualize its prediction you need to publish the training. You can run multiple trainings for same model to find best performing checkpoint, Each training will result different checkpoint. You can always publish another training. Click on PUblish Training button to Publish Model.
-
+
+21. Publish Your Training.Once you are statisfied accuracy and want to visualize its prediction you need to publish the training. You can run multiple trainings for same model to find best performing checkpoint, Each training will result different checkpoint. You can always publish another training. Click on PUblish Training button to Publish Model.
+
-## Steps to start access your project and Start mapping
+## Steps to start access your project and Start mapping
1. Start Mapping.Once Model is Published it will be listed here on Model page as Published Training ID , Click on Start Mapping to See its Prediction.
-
-
+
2. Visualize Your Model's Prediction.Zoom to the area you want to see predictions and Click Detect to Run your Published training Model. It will load the model and Run live predictions.
-
-
+
3. Bring Predictions to OSM.Your Predictions will be visualized on Map, Now you can bring them to OSM Modify them remove bad predictions and Push it back to OSM. fAIr should be able to have feedback loop when user discards the prediction or modifies it ( it is work in Prrogress) , you can launch JOSM with prediction data though.
-
-
+
## Steps to make changes to the prediction of a particular model
@@ -201,21 +143,18 @@ https://github.com/hotosm/fAIr/assets/97789856/f4eae99f-e9a2-4424-806f-fdfce80e6
4. All the feedbacks submitted need to be approved/validated by the project manager.
-
> Note: 'task' refers to each section of the map enclosed in the dotted
> lines and each task has a corresponding number tag.
-
-
-
-
-
## Help and Support
+
If you encounter any issues or need assistance while using fAIr, you can access the following resources:
-- Check the [FAQs](https://github.com/hotosm/fAIr/blob/master/docs/FAQ.md) .
+
+- Check the [FAQs](https://github.com/hotosm/fAIr/blob/main/docs/FAQ.md) .
- Join our Slack channel.
-## Thank you
+## Thank you
+
We are excited to have you join our community of passionate mappers and volunteers. fAIr is an AI powerful platform developed by the Humanitarian OpenStreetMap Team (HOT) that provides AI models and tools to automate mapping tasks, improving efficiency and accuracy.
With fAIr, you have the opportunity to make a real impact by mapping areas that are in need of support. Your contributions help create detailed and up-to-date maps that aid organizations and communities in their efforts to respond to crises, plan infrastructure, and improve the lives of people around the world.
@@ -229,4 +168,3 @@ Thank you for being part of fAIr. Your mapping efforts are invaluable, and we ap
Happy mapping!
The fAIr Team
-
diff --git a/docs/assets/fair-updates.json b/docs/assets/fair-updates.json
index 74c13918c..5ec5f9133 100644
--- a/docs/assets/fair-updates.json
+++ b/docs/assets/fair-updates.json
@@ -1,5 +1,11 @@
{
- "videos": [
- {"id": 1, "name":"Prediction Request Feature", "url":"https://www.youtube.com/watch?v=jRDBiSAgm3c", "slug":"prediction-request-feature", "date":"09-02-2026"}
- ]
+ "videos": [
+ {
+ "id": 1,
+ "name": "Prediction Request Feature",
+ "url": "https://www.youtube.com/watch?v=jRDBiSAgm3c",
+ "slug": "prediction-request-feature",
+ "date": "09-02-2026"
+ }
+ ]
}
diff --git a/docs/decisions/infra/0001-mlops.md b/docs/decisions/infra/0001-mlops.md
index 9891ce22d..fadd8a008 100644
--- a/docs/decisions/infra/0001-mlops.md
+++ b/docs/decisions/infra/0001-mlops.md
@@ -38,38 +38,46 @@ The stack:
### Why ZenML
**Good fit for a small team**
+
- Works out-of-the-box on Kubernetes without needing Kubeflow or a heavy control plane.
- Provides a ready-made ML workflow layer so we don’t have to build one ourselves.
**Lower maintenance than DIY**
+
- Gives us pipeline orchestration, artifact storage, and run metadata in one system.
- Reduces the amount of custom glue code we would otherwise need to maintain.
**Reproducibility by default**
+
- Tracks code, environments, artifacts, and pipeline versions automatically.
- Builds lineage between inputs, runs, and outputs without manual effort.
**Easy integration with fAIr**
+
- Can be triggered programmatically via the ZenML Python client, keeping a clean boundary:
- - fAIr = user workflows
+ - fAIr = user workflows
- ZenML = ML infrastructure
**Works with our stack**
+
- Native integrations with S3, MLflow, WandB, and model serving tools (Seldon/KServe).
- Runs directly on Kubernetes using ZenML’s built-in executor.
**STAC integration should be simple**
+
- ZenML will be our source of truth for ML run metadata and artifacts.
- We will publish selected outputs to pgSTAC via a simple post-run hook or final pipeline step.
### Why not the others
**Metaflow:**
+
- No automatic environment capture (depends on team discipline)
- Reproducibility is manual, not enforced
- We'd be building what ZenML already provides
**Flyte:**
+
- Heavier control plane (FlyteAdmin, FlyteConsole, separate database)
- More complex to operate than ZenML
- Built for larger scale than we currently need
@@ -81,6 +89,7 @@ Flyte is excellent but overkill for our current needs.
### When we'd reconsider
We'd move to **Flyte** if:
+
- We scale to 100+ runs per day
- We need multi-cluster execution across regions
- We add separate teams needing strict isolation
@@ -90,6 +99,7 @@ We'd move to **Flyte** if:
## Architecture
**Training & data processing:**
+
- Runs as ZenML pipelines
- Executes on Kubernetes (via ZenML's native orchestrator)
- Reads input data from S3/STAC
@@ -97,25 +107,27 @@ We'd move to **Flyte** if:
- All metadata tracked automatically
**Metadata registration:**
+
- ZenML tracks all pipeline/artifact metadata internally
- Post-run hook publishes summary to pgSTAC for geospatial discovery
- Optional: track experiments in MLflow/WandB via ZenML integrations
**Inference:**
+
- Deploy to Seldon/KServe via ZenML model deployer
- Or export to ONNX for client-side execution
## Trade-offs
-- ✅ Automatic reproducibility without team discipline required
-- ✅ Minimal custom code to maintain
-- ✅ Built for small teams without platform engineers
-- ✅ First-class integrations with our stack (S3, Seldon, MLflow)
-- ✅ Easy programmatic triggering from fAIr
-- ✅ Full lineage tracking and metadata management built-in
-- ✅ Clean separation: fAIr handles user workflows, ZenML handles ML infrastructure
-- ✅ Can migrate to Flyte later if needed (similar abstractions)
+- ✅ Automatic reproducibility without team discipline required
+- ✅ Minimal custom code to maintain
+- ✅ Built for small teams without platform engineers
+- ✅ First-class integrations with our stack (S3, Seldon, MLflow)
+- ✅ Easy programmatic triggering from fAIr
+- ✅ Full lineage tracking and metadata management built-in
+- ✅ Clean separation: fAIr handles user workflows, ZenML handles ML infrastructure
+- ✅ Can migrate to Flyte later if needed (similar abstractions)
- ❌ Adds MySQL database to maintain
-- ❌ Learning curve for ZenML concepts (stacks, materializers)
+- ❌ Learning curve for ZenML concepts (stacks, materializers)
- ❌ Slightly more opinionated than raw Metaflow
- ❌ ZenML's Kubernetes orchestrator is less mature than Argo Workflows
diff --git a/docs/deployment.md b/docs/deployment.md
index 4505994cc..f561cd83b 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -2,48 +2,51 @@ Checklist for the deployments:
**Pre-Deployment Checklist**
-* Perform sanity check on the dev env with model creation , predictions , dataset creation !
-* Backup the Database , `python manage.py backup_db myoutputpath/backup.sql`
-* Check and verify any database migration exists for the release if they do kindly verify the migrations files are stored in the git itself , production uses migrations from the repo
+- Perform sanity check on the dev env with model creation , predictions , dataset creation !
+- Backup the Database , `python manage.py backup_db myoutputpath/backup.sql`
+- Check and verify any database migration exists for the release if they do kindly verify the migrations files are stored in the git itself , production uses migrations from the repo
**Release Checklist**
-* Release fAIr utilities
-* Release fairpredictor
-* Verify the pypi releases for utilities and predictor
-* Make PR to include new versions from utilities and
- predictor & finally Release fAIr
-- Document the env variable changes that are required for this release as compared to the previous version
+- Release fAIr utilities
+- Release fairpredictor
+- Verify the pypi releases for utilities and predictor
+- Make PR to include new versions from utilities and
+ predictor & finally Release fAIr
-Make sure you always follow this order because : new version of fAIr utilities and fAIrpredictor should be included in the fAIr backend envs, hence it can only be done after first two release . A PR would be required to bump it to new versions and docker images should be built for the prediciton and new release of fAIr should include those versions from utilties and predictor !
+* Document the env variable changes that are required for this release as compared to the previous version
+
+Make sure you always follow this order because : new version of fAIr utilities and fAIrpredictor should be included in the fAIr backend envs, hence it can only be done after first two release . A PR would be required to bump it to new versions and docker images should be built for the prediciton and new release of fAIr should include those versions from utilties and predictor !
**Database Migration Checklist**
-Make sure you have backups available in case things go wrong !
-* Login to SSH
-* Login as admin
-* Checkout to release
-* Activate virtualenv (source fAIr/backend/.venv/bin/activate)
-* Verify changes
-* Run `python manage.py migrate`
+Make sure you have backups available in case things go wrong !
+
+- Login to SSH
+- Login as admin
+- Checkout to release
+- Activate virtualenv (source fAIr/backend/.venv/bin/activate)
+- Verify changes
+- Run `python manage.py migrate`
**Deployment Checklist**
-* Verify PYPI packages are available:
- + hot-fair-utilities (https://pypi.org/project/hot-fair-utilities/)
- + fairpredictor (https://pypi.org/project/fairpredictor/)
+- Verify PYPI packages are available:
+ - hot-fair-utilities (https://pypi.org/project/hot-fair-utilities/)
+ - fairpredictor (https://pypi.org/project/fairpredictor/)
-* Verify Docker images for fAIr production are built and deployed
-* [worker](https://github.com/hotosm/fAIr/pkgs/container/fair/worker) , [api](https://github.com/hotosm/fAIr/pkgs/container/fair/api) & [offline-predictor](https://github.com/hotosm/fAIr/pkgs/container/fair-offline-predictor ) images should be built and pointed to latest release
-* Verify Docker image for [fairpredictor](https://github.com/hotosm/fairpredictor/pkgs/container/fairpredictor) is built and deployed
-- Now create new task definition for api , worker , predictor and prediction worker , Verify the env variable changes if there are any
-- Deploy the services
+- Verify Docker images for fAIr production are built and deployed
+- [worker](https://github.com/hotosm/fAIr/pkgs/container/fair/worker) , [api](https://github.com/hotosm/fAIr/pkgs/container/fair/api) & [offline-predictor](https://github.com/hotosm/fAIr/pkgs/container/fair-offline-predictor) images should be built and pointed to latest release
+- Verify Docker image for [fairpredictor](https://github.com/hotosm/fairpredictor/pkgs/container/fairpredictor) is built and deployed
+* Now create new task definition for api , worker , predictor and prediction worker , Verify the env variable changes if there are any
+* Deploy the services
**Verification Checklist**
-* Verify all the workers are appearing in flower
-* Verify the full workflow in frontend !
-- Check the cache for the s3 (frontend cloudfront)
-* Verify Matomo after deployment
+- Verify all the workers are appearing in flower
+- Verify the full workflow in frontend !
+
+* Check the cache for the s3 (frontend cloudfront)
+- Verify Matomo after deployment
diff --git a/docs/infra/dev.md b/docs/infra/dev.md
index fa54749c9..ff34c2744 100644
--- a/docs/infra/dev.md
+++ b/docs/infra/dev.md
@@ -15,11 +15,11 @@ branch: CI builds the images on every push, and a redeploy pulls them.
Everything lives in `/opt/fAIr-app` (a `develop` checkout):
-| File | Purpose |
-|---|---|
-| `docker-compose.yml` | base stack (api, worker, postgres, minio, stac, mlflow, zenml, frontend) |
-| `docker-compose.dev.yml` | dev override: Caddy ingress, restart policies, the inline Caddyfile |
-| `.env` | all runtime config and secrets (not in git) |
+| File | Purpose |
+| ------------------------ | ------------------------------------------------------------------------ |
+| `docker-compose.yml` | base stack (api, worker, postgres, minio, stac, mlflow, zenml, frontend) |
+| `docker-compose.dev.yml` | dev override: Caddy ingress, restart policies, the inline Caddyfile |
+| `.env` | all runtime config and secrets (not in git) |
`.env` sets `COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml`, so plain
`docker compose` commands pick up both files. The stack is managed by the
diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit
deleted file mode 100755
index 5ecd813c5..000000000
--- a/frontend/.husky/pre-commit
+++ /dev/null
@@ -1,3 +0,0 @@
-cd frontend
-pnpm format
-pnpm build
\ No newline at end of file
diff --git a/frontend/README.md b/frontend/README.md
index 483f9ac2c..bb486281e 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -116,7 +116,7 @@ Here's an overview of the folder structure:
│ ├── utils/ - Utility functions, application content and constants.
│ └── main.tsx - Entry point of the React app.
├── docs/ - ARD documentation for some of the decisions made for the app.
-└── vercel.json - To prevent the custom 404 page from Vercel when a route is visited. (This is just for the demo site deployed on Vercel.)
+└── vercel.json - To prevent the custom 404 page from Vercel when a route is visited. (This is just for the demo site deployed on Vercel.)
└── ... Other configuration files like tsconfig.json, vite.config.mts etc.
```
@@ -192,4 +192,4 @@ fAIr also bundles portions of the following open source software.
- [Maplibre GL JS (BSD-3-Clause)](https://github.com/maplibre/maplibre-gl-js).
- [PMTiles (BSD-3-Clause)](https://github.com/protomaps/PMTiles).
- [React Medium Image Zoom (BSD-3-Clause)](https://github.com/rpearce/react-medium-image-zoom).
-- Map fonts from [Maplibre Demo Tiles](https://github.com/maplibre/demotiles).
\ No newline at end of file
+- Map fonts from [Maplibre Demo Tiles](https://github.com/maplibre/demotiles).
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 6b191c342..424a6311b 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -1,42 +1,36 @@
-import js from '@eslint/js';
-import globals from 'globals';
-import reactHooks from 'eslint-plugin-react-hooks';
-import reactRefresh from 'eslint-plugin-react-refresh';
-import tseslint from '@typescript-eslint/eslint-plugin';
-import prettierPlugin from 'eslint-plugin-prettier';
-import prettierConfig from 'eslint-config-prettier';
-
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "@typescript-eslint/eslint-plugin";
+import prettierPlugin from "eslint-plugin-prettier";
+import prettierConfig from "eslint-config-prettier";
export default [
-
{
- ignores: ['dist'],
- files: ['**/*.{ts,tsx}'],
+ ignores: ["dist"],
+ files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
- sourceType: 'module',
+ sourceType: "module",
globals: globals.browser,
- parser: '@typescript-eslint/parser',
+ parser: "@typescript-eslint/parser",
},
plugins: {
- 'react-hooks': reactHooks,
- 'react-refresh': reactRefresh,
- '@tanstack/query': '@tanstack/query',
- 'prettier': prettierPlugin,
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ "@tanstack/query": "@tanstack/query",
+ prettier: prettierPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
- 'react-refresh/only-export-components': [
- 'warn',
- { allowConstantExport: true },
- ],
- 'prettier/prettier': 'error',
-
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+ "prettier/prettier": "error",
},
},
js.configs.recommended,
...tseslint.configs.recommended,
- 'plugin:@tanstack/eslint-plugin-query/recommended',
- 'plugin:tailwindcss/recommended',
+ "plugin:@tanstack/eslint-plugin-query/recommended",
+ "plugin:tailwindcss/recommended",
prettierConfig,
];
diff --git a/frontend/index.html b/frontend/index.html
index b29b66314..4ed803e83 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,31 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ HOT fAIr
- HOT fAIr
+
+
+
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+