Spaces:
Running
OMyFish β Architecture Refactor Plan
Current State Analysis
What exists
| File | Concerns mixed inside |
|---|---|
app/api.py |
Routes + raw SQL + GeoJSON build + EXIF extraction + predictor loading |
app/main.py (Streamlit) |
UI + direct DB writes (raw SQL) + map rendering + EXIF extraction |
app/database.py |
Engine + DDL + SQLite/PostGIS dual-path |
app/gis.py |
Single function: extract_exif_gps() |
app/clip_predictor.py |
CLIP zero-shot AI predictor |
src/ |
Training pipeline (model, dataset, train, evaluate, predict, transforms) |
Key problems
- No service layer β routes call predictors and hit the DB directly
- No repository layer β raw SQL strings live inside routes and Streamlit pages
- GIS is vestigial β one function; GeoJSON built inline in routes
- Two predictors are disconnected β
FishPredictorandCLIPFishPredictorhave the samepredict()interface but no shared contract - Config is hardcoded β DB URL, thresholds, checkpoint paths scattered across files
- PostGIS already partially wired β
geom GEOGRAPHY(POINT,4326)is ininit_db()but created through raw DDL, no migrations
1. Target Folder Structure
omyfish-python/
βββ apps/
β βββ omyfish-api/ # FastAPI backend
β β βββ main.py # App factory + middleware
β β βββ routes/
β β β βββ observations.py
β β β βββ species.py
β β β βββ gis.py
β β β βββ health.py
β β βββ repositories/
β β β βββ observation_repository.py
β β β βββ species_repository.py
β β βββ db/
β β βββ engine.py # SQLAlchemy engine setup
β β βββ migrations/ # Alembic migrations
β β
β βββ omyfish-web/ # Streamlit frontend
β β βββ main.py
β β βββ pages/
β β βββ identify.py
β β βββ map.py
β β
β βββ omyfish-admin/ # Placeholder β future admin dashboard
β βββ __init__.py
β
βββ services/
β βββ fish-ai/ # All ML logic
β β βββ service.py # FishAIService (unified interface)
β β βββ predictors/
β β β βββ base.py # BaseFishPredictor ABC
β β β βββ efficientnet.py # FishPredictor (trained model)
β β β βββ clip.py # CLIPFishPredictor (zero-shot)
β β βββ training/ # move src/train.py, dataset.py, transforms.py here
β β β βββ train.py
β β β βββ dataset.py
β β β βββ evaluate.py
β β β βββ transforms.py
β β βββ model/ # move src/model.py here
β β β βββ classifier.py
β β βββ tests/
β β
β βββ gis-service/ # All geospatial logic
β β βββ service.py # GISService
β β βββ exif.py # extract_exif_gps (move from app/gis.py)
β β βββ geojson.py # GeoJSON builders
β β βββ spatial_queries.py # PostGIS spatial queries
β β βββ tests/
β β
β βββ ingestion-service/ # Placeholder β external data sources
β β βββ interfaces.py # ObservationSource ABC
β β βββ __init__.py
β β
β βββ analytics-service/ # Placeholder β heatmaps, distributions
β βββ interfaces.py # AnalyticsService ABC
β βββ __init__.py
β
βββ shared/
β βββ models/
β β βββ observation.py # SQLAlchemy ORM + Pydantic schemas
β β βββ species.py
β β βββ user.py
β β βββ location.py
β βββ schemas/
β β βββ observation.py # Request/response Pydantic models
β β βββ prediction.py
β β βββ geojson.py
β βββ constants/
β β βββ thresholds.py # UNCERTAIN_THRESHOLD etc.
β βββ utils/
β βββ ids.py # new_id(), UUID helpers
β
βββ data/
β βββ raw/
β βββ processed/
β βββ training/
β βββ metadata/
β βββ exports/
β
βββ infrastructure/
β βββ docker/
β β βββ docker-compose.yml # move from root
β β βββ docker-compose.prod.yml
β β βββ Dockerfile # move from root
β βββ k8s/ # Future Kubernetes manifests
β
βββ configs/
β βββ development.yaml
β βββ production.yaml
β βββ training.yaml # move config.yaml here
β
βββ docs/
β βββ ARCHITECTURE_REFACTOR.md # this file
β
βββ research/ # notebooks/, scripts/
βββ notebooks/
βββ scripts/
2. Architecture Diagram
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CLIENT LAYER β
β β
β omyfish-web (Streamlit) omyfish-admin (future) β
ββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β HTTP
ββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββ
β omyfish-api (FastAPI) β
β β
β routes/ repositories/ β
β βββ observations βββ ObservationRepository ββββββββββββ
β βββ species βββ SpeciesRepository ββ
β βββ gis ββ
β βββ health ββ
ββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββ¬ββ
β calls β ORM
ββββββββΌβββββββββββββββββββ βββββββββββββββββββΌβββ
β services/fish-ai β β PostgreSQL β
β β β + PostGIS β
β FishAIService β β β
β βββ EfficientNet mode β β observations β
β βββ CLIP zero-shot β β (geom GEOGRAPHY) β
βββββββββββββββββββββββββββ ββββββββββββββββββββββ
ββββββββββββββββββββββββββββ
β services/gis-service β
β β
β GISService β
β βββ EXIF extraction β
β βββ GeoJSON building β
β βββ spatial queries ββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββ β
β
PostGIS spatial
functions (ST_*)
3. Domain Model Definitions
Observation (core entity)
# shared/models/observation.py
from uuid import UUID, uuid4
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, String, Float, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from geoalchemy2 import Geography
from shared.db import Base
class Observation(Base):
__tablename__ = "observations"
id: UUID = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
species_name: str = Column(String, nullable=False)
scientific_name: str = Column(String)
confidence: float = Column(Float, nullable=False)
timestamp: datetime = Column(DateTime(timezone=True), default=datetime.utcnow)
latitude: float = Column(Float)
longitude: float = Column(Float)
geom = Column(Geography("POINT", srid=4326)) # PostGIS
image_url: str = Column(Text)
user_id: str = Column(String)
source: str = Column(String, default="upload")
Species (knowledge entity)
# shared/models/species.py
from pydantic import BaseModel
from typing import Optional
class Species(BaseModel):
name: str
scientific_name: Optional[str]
habitat: Optional[str]
diet: Optional[str]
max_size_cm: Optional[float]
conservation_status: Optional[str]
description: Optional[str]
fun_fact: Optional[str]
Location (value object)
# shared/models/location.py
from pydantic import BaseModel
from typing import Optional
class Coordinates(BaseModel):
latitude: float
longitude: float
class GeoPoint(BaseModel):
coordinates: Coordinates
srid: int = 4326
source: str # "manual" | "exif" | "gps"
Prediction (value object β never persisted directly)
# shared/schemas/prediction.py
from pydantic import BaseModel
from typing import Optional, List
from shared.models.species import Species
class PredictionResult(BaseModel):
species: str
confidence: float
metadata: Optional[Species]
class PredictionResponse(BaseModel):
predictions: List[PredictionResult]
uncertain: bool
message: Optional[str]
top_species: str # convenience: predictions[0].species
top_confidence: float # convenience: predictions[0].confidence
Events (domain events)
# shared/events.py
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
class DomainEvent(BaseModel):
event_id: UUID
occurred_at: datetime
class ObservationCreated(DomainEvent):
observation_id: UUID
species_name: str
latitude: float
longitude: float
source: str
class SpeciesPredicted(DomainEvent):
species_name: str
confidence: float
uncertain: bool
class ObservationValidated(DomainEvent):
observation_id: UUID
validated_by: str
is_correct: bool
4. Service Interfaces
Fish AI Service
# services/fish-ai/service.py
from abc import ABC, abstractmethod
from typing import List
from PIL import Image
from shared.schemas.prediction import PredictionResponse, PredictionResult
from shared.models.species import Species
class BaseFishPredictor(ABC):
@abstractmethod
def predict(self, image: Image.Image, top_k: int = 3) -> PredictionResponse:
...
class FishAIService:
"""
Unified entry point for AI inference.
Delegates to EfficientNet if checkpoint exists, CLIP otherwise.
"""
def __init__(self, predictor: BaseFishPredictor):
self._predictor = predictor
def predict_species(self, image: Image.Image) -> PredictionResponse:
return self._predictor.predict(image, top_k=1)
def get_top_predictions(self, image: Image.Image, top_k: int = 3) -> PredictionResponse:
return self._predictor.predict(image, top_k=top_k)
def get_species_info(self, species_name: str) -> Optional[Species]:
# Looks up the knowledge base β no model inference needed
...
GIS Service
# services/gis-service/service.py
from abc import ABC
from typing import Optional, List
from PIL import Image
from shared.models.location import Coordinates, GeoPoint
from shared.models.observation import Observation
class GISService:
def create_observation_point(self, lat: float, lon: float, source: str) -> GeoPoint:
...
def extract_gps_from_image(self, image: Image.Image) -> Optional[Coordinates]:
...
def export_geojson(self, observations: List[Observation]) -> dict:
...
def observations_within_radius(
self,
center: Coordinates,
radius_m: float,
) -> List[Observation]:
# Delegates to spatial_queries.py (PostGIS ST_DWithin)
...
def nearest_observation(self, point: Coordinates) -> Optional[Observation]:
...
Ingestion Service (placeholder)
# services/ingestion-service/interfaces.py
from abc import ABC, abstractmethod
from typing import List, Iterator
from shared.models.observation import Observation
class ObservationSource(ABC):
"""Interface for external observation data providers."""
@abstractmethod
def fetch_observations(self) -> Iterator[Observation]:
...
@abstractmethod
def source_name(self) -> str:
...
# Future implementations:
# class INaturalistSource(ObservationSource): ...
# class GBIFSource(ObservationSource): ...
# class GovernmentDatasetSource(ObservationSource): ...
Analytics Service (placeholder)
# services/analytics-service/interfaces.py
from abc import ABC, abstractmethod
from typing import List
from shared.models.observation import Observation
class AnalyticsService(ABC):
@abstractmethod
def generate_heatmap(self, observations: List[Observation]) -> dict:
...
@abstractmethod
def species_distribution(self, species_name: str) -> dict:
...
@abstractmethod
def density_estimation(self, bbox: tuple) -> dict:
...
5. Repository Interfaces
# apps/omyfish-api/repositories/observation_repository.py
from abc import ABC, abstractmethod
from uuid import UUID
from typing import Optional, List
from shared.models.observation import Observation
from shared.models.location import Coordinates
class ObservationRepository(ABC):
@abstractmethod
def create(self, obs: ObservationCreate) -> Observation:
...
@abstractmethod
def get_by_id(self, id: UUID) -> Optional[Observation]:
...
@abstractmethod
def list(self, limit: int = 100) -> List[Observation]:
...
@abstractmethod
def list_within_radius(
self, center: Coordinates, radius_m: float
) -> List[Observation]:
...
@abstractmethod
def list_as_geojson(self, limit: int = 1000) -> dict:
...
class SQLObservationRepository(ObservationRepository):
"""PostgreSQL/PostGIS implementation."""
def __init__(self, session):
self._session = session
def create(self, obs: ObservationCreate) -> Observation:
# Uses SQLAlchemy ORM; PostGIS geom set via ST_MakePoint
...
def list_within_radius(self, center: Coordinates, radius_m: float) -> List[Observation]:
# ST_DWithin(geom, ST_MakePoint(:lon,:lat)::geography, :radius_m)
...
# apps/omyfish-api/repositories/species_repository.py
class SpeciesRepository(ABC):
@abstractmethod
def get_by_name(self, name: str) -> Optional[Species]:
...
@abstractmethod
def list_all(self) -> List[Species]:
...
class JSONSpeciesRepository(SpeciesRepository):
"""File-based implementation backed by data/metadata/fish_info.json."""
...
6. PostGIS Migration Strategy
Current state
init_db()inapp/database.pycreates the schema via raw DDL on startup- PostGIS
geomcolumn already present whenDATABASE_URLis PostgreSQL - No migration history β schema is recreated on every
init_db()call
Target state
Step 1: Add Alembic
pip install alembic geoalchemy2
alembic init apps/omyfish-api/db/migrations
Step 2: Initial migration β captures the current schema
# alembic/versions/001_initial_schema.py
def upgrade():
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
op.create_table(
"observations",
sa.Column("id", PG_UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("species_name", sa.Text()),
sa.Column("scientific_name", sa.Text()),
sa.Column("confidence", sa.Float()),
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("latitude", sa.Float()),
sa.Column("longitude", sa.Float()),
sa.Column("geom", Geography("POINT", srid=4326)),
sa.Column("image_url", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("source", sa.Text(), server_default="upload"),
)
op.create_index("observations_geom_idx", "observations", ["geom"], postgresql_using="gist")
Step 3: Drop init_db() DDL β replace with alembic upgrade head on startup
Step 4: Future spatial migrations are versioned
# alembic/versions/002_add_species_table.py
# alembic/versions/003_add_validated_flag.py
SQLite fallback
Keep SQLite for local dev/HF Spaces with --dev flag. Alembic handles only the PostgreSQL path; SQLite uses a lightweight DDL shim (no migration history needed there).
7. Docker Architecture
# infrastructure/docker/docker-compose.yml
services:
postgis:
image: postgis/postgis:16-3.4
environment:
POSTGRES_DB: omyfish
POSTGRES_USER: omyfish
POSTGRES_PASSWORD: omyfish
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U omyfish"]
api:
build:
context: ../../
dockerfile: infrastructure/docker/Dockerfile.api
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://omyfish:omyfish@postgis:5432/omyfish
ENV: development
depends_on:
postgis:
condition: service_healthy
command: uvicorn apps.omyfish-api.main:app --host 0.0.0.0 --port 8000 --reload
web:
build:
context: ../../
dockerfile: infrastructure/docker/Dockerfile.web
ports:
- "8501:8501"
environment:
API_URL: http://api:8000
DATABASE_URL: postgresql://omyfish:omyfish@postgis:5432/omyfish
depends_on:
- api
command: streamlit run apps/omyfish-web/main.py --server.port=8501
volumes:
pgdata:
Notes:
fish-aiis NOT a separate container for now β it runs in-process insideapi. Extract to its own container only when GPU inference is needed at scale.webcan be optionally pointed atAPI_URLinstead of direct DB β this is the cleaner architecture for production.
8. Dependency Flow
omyfish-web βββββββββββββββββββββββββββββββΊ omyfish-api
β
βββββββββββββββΌββββββββββββββ
βΌ βΌ βΌ
routes/ repositories/ services/
β
ββββββββββββ΄βββββββββββ
βΌ βΌ
PostgreSQL shared/models
+ PostGIS β
ββββββββ΄βββββββ
βΌ βΌ
fish-ai/ gis-service/
β
ββββββββ΄βββββββ
βΌ βΌ
EfficientNet CLIP zero-shot
(checkpoints/) (HuggingFace Hub)
Dependency rules:
shared/ β no dependencies on apps/ or services/
services/ β depends on shared/ only
apps/ β depends on services/ and shared/
routes/ β depends on repositories/ and services/ (never on DB directly)
repositories/β depends on shared/models/ and DB session
9. API Redesign
Route layout
GET /health
POST /species/predict # image β PredictionResponse
GET /species/{name} # species metadata lookup
POST /observations # create from manual input
GET /observations # list, with ?limit=
GET /observations/{id} # single observation
GET /observations/geojson # FeatureCollection for map
GET /observations/nearby # ?lat=&lon=&radius_m=
Request/response contracts
# POST /species/predict
# Request: multipart/form-data β file=<image>, top_k=3
# Response:
{
"predictions": [
{"species": "Atlantic Salmon", "confidence": 0.87, "metadata": {...}},
...
],
"uncertain": false,
"message": null,
"top_species": "Atlantic Salmon",
"top_confidence": 0.87
}
# POST /observations
# Request body:
{
"species_name": "Atlantic Salmon",
"scientific_name": "Salmo salar",
"confidence": 0.87,
"latitude": 47.5,
"longitude": -52.8,
"source": "manual"
}
# Response:
{
"id": "uuid",
"status": "created"
}
# GET /observations/geojson
# Response: GeoJSON FeatureCollection (RFC 7946)
Design rules for routes
- Routes must only call services and repositories β never the DB directly
- All SQL lives in repositories
- All AI logic lives in
FishAIService - All GIS logic lives in
GISService
10. Incremental Refactoring Plan
Priority: never break the running app between phases.
Phase 1 β Folder restructure (no logic changes)
Risk: Low. Pure file moves + import updates.
- Create new directory skeleton
- Move files to new locations:
app/clip_predictor.pyβservices/fish-ai/predictors/clip.pysrc/predict.pyβservices/fish-ai/predictors/efficientnet.pysrc/model.pyβservices/fish-ai/model/classifier.pysrc/train.py,dataset.py,transforms.py,evaluate.pyβservices/fish-ai/training/app/gis.pyβservices/gis-service/exif.pyapp/api.pyβapps/omyfish-api/main.py(minimal, still monolithic for now)streamlit_app.pyβapps/omyfish-web/main.pyapp/database.pyβapps/omyfish-api/db/engine.pyconfigs/config.yamlβconfigs/training.yaml
- Update all import paths
- Update
Makefileanddocker-compose.ymlpaths - Verify:
make apiandmake appstill work
Phase 2 β Service layer for AI
Risk: Low. Wraps existing code, no SQL changes.
- Create
services/fish-ai/predictors/base.pywithBaseFishPredictorABC - Make
CLIPFishPredictorandFishPredictorimplement it - Create
services/fish-ai/service.pywithFishAIServicewrapping both - Add a factory function:
build_predictor(checkpoint_path) -> BaseFishPredictor - Update
apps/omyfish-api/main.pyandapps/omyfish-web/main.pyto useFishAIService - Verify: predictions still work in both CLIP and trained modes
Phase 3 β Repository layer
Risk: Medium. Touches all DB access.
- Create
shared/models/observation.pyβ SQLAlchemy ORM model - Create
apps/omyfish-api/repositories/observation_repository.py - Create
apps/omyfish-api/repositories/species_repository.py(JSON-backed) - Replace
_insert_observation()inmain.py(api) withObservationRepository.create() - Replace raw SQL queries in list/geojson routes with repository calls
- Replace raw SQL in Streamlit app with repository calls
- Verify: saving and listing observations works
Phase 4 β GIS service + shared schemas
Risk: Low.
- Create
services/gis-service/service.pywithGISService - Move EXIF extraction, GeoJSON building into it
- Create
shared/schemas/prediction.py,shared/schemas/observation.py - Replace inline
ObservationInPydantic model in routes with shared schema - Verify:
/identify-fishand/observations/geojsonendpoints still work
Phase 5 β API routes split
Risk: Low.
- Split
apps/omyfish-api/main.pyroutes intoroutes/observations.py,routes/species.py,routes/gis.py,routes/health.py - Wire them up in
main.pyviaapp.include_router(...) - Verify: all endpoints still respond
Phase 6 β Config management
Risk: Low.
- Add
pydantic-settingsdependency - Create
configs/development.yamlandconfigs/production.yaml - Replace scattered hardcoded values (DB URL, thresholds, checkpoint paths) with config-loaded values
- Support: local (yaml), docker (env vars), cloud (env vars)
- Verify: app starts cleanly in both modes
Phase 7 β Placeholder services + events
Risk: None (new files only).
- Create
services/ingestion-service/interfaces.py - Create
services/analytics-service/interfaces.py - Create
shared/events.pywithObservationCreated,SpeciesPredicted,ObservationValidated - Emit
ObservationCreatedfromObservationRepository.create()(logged only, no queue yet)
Phase 8 β Alembic migrations
Risk: Medium (DB schema management change).
- Install
alembicandgeoalchemy2 alembic init apps/omyfish-api/db/migrations- Generate initial migration from current schema
- Replace
init_db()call withalembic upgrade headin API startup - Verify: fresh
docker-compose upcreates schema from migrations
Phase 9 β Docker refactor
Risk: Low.
- Move
Dockerfiletoinfrastructure/docker/ - Split into
Dockerfile.apiandDockerfile.web(they're the same image today, which wastes space) - Update
docker-compose.ymltoinfrastructure/docker/docker-compose.yml - Update
Makefilepaths - Verify:
docker-compose upbrings up all services
Implementation Order Summary
| Phase | Work | Risk | Breaks MVP? |
|---|---|---|---|
| 1 | Folder restructure | Low | No |
| 2 | Fish AI service layer | Low | No |
| 3 | Repository layer | Medium | No |
| 4 | GIS service + shared schemas | Low | No |
| 5 | API routes split | Low | No |
| 6 | Config management | Low | No |
| 7 | Placeholder services + events | None | No |
| 8 | Alembic migrations | Medium | No |
| 9 | Docker refactor | Low | No |
Total: 9 phases, each independently verifiable, zero downtime.
Generated: 2026-06-06 | Status: Ready for implementation