2026-05-22 19:34:11 +00:00
# ML Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a standalone FastAPI service at `apps/ml-service` that serves health, metadata, and corn leaf disease image predictions from the trained Keras model.
**Architecture:** The service is a small Python app separate from the Bun/Elysia API. `main.py` owns HTTP routes, `model.py` owns model loading/preprocessing/inference, and `schemas.py` defines response shapes. Runtime configuration is environment-variable based with safe defaults pointing back to `Machine_Learning/best_model/best_model.keras` .
**Tech Stack:** Python 3.9-3.11, FastAPI, Uvicorn, TensorFlow/Keras, Pillow, python-multipart, Pydantic.
---
## File Structure
Create these files:
- `apps/ml-service/requirements.txt` — Python dependencies for the inference service.
- `apps/ml-service/.env.example` — documented local configuration.
- `apps/ml-service/schemas.py` — Pydantic response models.
- `apps/ml-service/model.py` — model configuration, model loading, image preprocessing, and prediction logic.
- `apps/ml-service/main.py` — FastAPI app and route handlers.
- `apps/ml-service/README.md` — concise run and verification instructions for the service.
Modify these files:
- `.moon/workspace.yml` — register the Python service as a Moon project.
- `package.json` — include `apps/ml-service` in workspace discovery if needed by existing workspace pattern. No change is needed because `apps/*` already includes it.
Do not modify these files:
- `Machine_Learning/*` — remains the model pipeline and artifact location.
- `apps/api/*` — API-to-ML-service integration is out of scope for this plan.
- `apps/web/*` — frontend changes are out of scope.
---
### Task 1: Add service dependencies and configuration
**Files:**
- Create: `apps/ml-service/requirements.txt`
- Create: `apps/ml-service/.env.example`
- Modify: `.moon/workspace.yml`
- [ ] **Step 1: Create the service directory**
Run:
```bash
mkdir -p apps/ml-service
```
Expected: command exits successfully and `apps/ml-service` exists.
- [ ] **Step 2: Write dependency file**
Create `apps/ml-service/requirements.txt` with exactly:
```txt
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
tensorflow>=2.13.0
pillow>=10.0.0
python-multipart>=0.0.9
```
- [ ] **Step 3: Write example environment file**
Create `apps/ml-service/.env.example` with exactly:
```dotenv
MODEL_PATH=../../Machine_Learning/best_model/best_model.keras
MODEL_INPUT_SIZE=224
ML_SERVICE_HOST=0.0.0.0
ML_SERVICE_PORT=8001
```
- [ ] **Step 4: Register Moon project**
Modify `.moon/workspace.yml` so it becomes exactly:
```yaml
projects :
web : apps/web
api : apps/api
ml-service : apps/ml-service
shared : packages/shared
```
- [ ] **Step 5: Verify files are present**
Run:
```bash
ls apps/ml-service && grep -n "ml-service" .moon/workspace.yml
```
Expected: output includes `requirements.txt` , `.env.example` may not show because `ls` hides dotfiles, and `ml-service: apps/ml-service` appears from grep.
- [ ] **Step 6: Commit**
Run:
```bash
git add .moon/workspace.yml apps/ml-service/requirements.txt apps/ml-service/.env.example
git commit -m "feat: scaffold ML service configuration"
```
Expected: commit succeeds.
---
### Task 2: Add response schemas
**Files:**
- Create: `apps/ml-service/schemas.py`
- [ ] **Step 1: Write schemas**
Create `apps/ml-service/schemas.py` with exactly:
```python
from pydantic import BaseModel
class HealthResponse ( BaseModel ):
status : str
model_loaded : bool
class MetadataResponse ( BaseModel ):
service_name : str
service_version : str
model_path : str
model_loaded : bool
input_size : int
labels : list [ str ]
class PredictionResponse ( BaseModel ):
label : str
confidence : float
probabilities : dict [ str , float ]
```
- [ ] **Step 2: Verify syntax**
Run:
```bash
python -m py_compile apps/ml-service/schemas.py
```
Expected: command exits with no output.
- [ ] **Step 3: Commit**
Run:
```bash
git add apps/ml-service/schemas.py
git commit -m "feat: add ML service response schemas"
```
Expected: commit succeeds.
---
### Task 3: Add model loading and prediction logic
**Files:**
- Create: `apps/ml-service/model.py`
- [ ] **Step 1: Write model module**
Create `apps/ml-service/model.py` with exactly:
```python
from io import BytesIO
import os
from pathlib import Path
import numpy as np
from PIL import Image , UnidentifiedImageError
import tensorflow as tf
LABELS = [ "Bercak Daun" , "Hawar Daun" , "Karat Daun" , "Daun Sehat" ]
SERVICE_NAME = "zeavis-ml-service"
SERVICE_VERSION = "0.1.0"
class ImageDecodeError ( ValueError ):
pass
class ModelService :
def __init__ ( self ) -> None :
self . input_size = int ( os . getenv ( "MODEL_INPUT_SIZE" , "224" ))
self . model_path = self . _resolve_model_path ( os . getenv ( "MODEL_PATH" , "../../Machine_Learning/best_model/best_model.keras" ))
self . model : tf . keras . Model | None = None
self . load_error : str | None = None
def _resolve_model_path ( self , model_path : str ) -> Path :
path = Path ( model_path )
if path . is_absolute ():
return path
return ( Path ( __file__ ) . resolve () . parent / path ) . resolve ()
@property
def model_loaded ( self ) -> bool :
return self . model is not None
def load ( self ) -> None :
try :
self . model = tf . keras . models . load_model ( self . model_path , compile = False )
self . load_error = None
except Exception as exc :
self . model = None
self . load_error = str ( exc )
def preprocess ( self , image_bytes : bytes ) -> np . ndarray :
try :
image = Image . open ( BytesIO ( image_bytes )) . convert ( "RGB" )
except ( UnidentifiedImageError , OSError ) as exc :
raise ImageDecodeError ( "Uploaded file is not a valid image" ) from exc
image = image . resize (( self . input_size , self . input_size ))
image_array = np . asarray ( image , dtype = np . float32 )
return np . expand_dims ( image_array , axis = 0 )
def predict ( self , image_bytes : bytes ) -> tuple [ str , float , dict [ str , float ]]:
if self . model is None :
raise RuntimeError ( "Model is not loaded" )
batch = self . preprocess ( image_bytes )
raw_predictions = self . model . predict ( batch , verbose = 0 )[ 0 ]
probabilities_array = np . asarray ( raw_predictions , dtype = np . float32 )
top_index = int ( np . argmax ( probabilities_array ))
probabilities = {
label : float ( probabilities_array [ index ])
for index , label in enumerate ( LABELS )
}
return LABELS [ top_index ], float ( probabilities_array [ top_index ]), probabilities
model_service = ModelService ()
```
- [ ] **Step 2: Verify syntax**
Run:
```bash
python -m py_compile apps/ml-service/model.py
```
Expected: command exits with no output if TensorFlow and dependencies are installed in the active Python environment. If it fails with `ModuleNotFoundError: No module named 'tensorflow'` , install dependencies in a virtual environment before continuing:
```bash
python -m venv apps/ml-service/.venv
apps/ml-service/.venv/bin/pip install -r apps/ml-service/requirements.txt
apps/ml-service/.venv/bin/python -m py_compile apps/ml-service/model.py
```
Expected after dependency install: command exits with no output.
- [ ] **Step 3: Commit**
Run:
```bash
git add apps/ml-service/model.py
git commit -m "feat: add ML model prediction service"
```
Expected: commit succeeds.
---
### Task 4: Add FastAPI routes
**Files:**
- Create: `apps/ml-service/main.py`
- [ ] **Step 1: Write FastAPI app**
Create `apps/ml-service/main.py` with exactly:
```python
from fastapi import FastAPI , File , HTTPException , UploadFile
from model import ImageDecodeError , LABELS , SERVICE_NAME , SERVICE_VERSION , model_service
from schemas import HealthResponse , MetadataResponse , PredictionResponse
app = FastAPI ( title = "ZeaVis ML Service" , version = SERVICE_VERSION )
@app.on_event ( "startup" )
def load_model () -> None :
model_service . load ()
@app.get ( "/health" , response_model = HealthResponse )
def health () -> HealthResponse :
return HealthResponse ( status = "ok" , model_loaded = model_service . model_loaded )
@app.get ( "/metadata" , response_model = MetadataResponse )
def metadata () -> MetadataResponse :
return MetadataResponse (
service_name = SERVICE_NAME ,
service_version = SERVICE_VERSION ,
model_path = str ( model_service . model_path ),
model_loaded = model_service . model_loaded ,
input_size = model_service . input_size ,
labels = LABELS ,
)
@app.post ( "/predict" , response_model = PredictionResponse )
async def predict ( file : UploadFile = File ( ... )) -> PredictionResponse :
if file . content_type is None or not file . content_type . startswith ( "image/" ):
raise HTTPException ( status_code = 400 , detail = "Uploaded file must be an image" )
if not model_service . model_loaded :
raise HTTPException ( status_code = 503 , detail = "Model is not loaded" )
image_bytes = await file . read ()
try :
label , confidence , probabilities = model_service . predict ( image_bytes )
except ImageDecodeError as exc :
raise HTTPException ( status_code = 400 , detail = str ( exc )) from exc
except Exception as exc :
raise HTTPException ( status_code = 500 , detail = "Prediction failed" ) from exc
return PredictionResponse (
label = label ,
confidence = confidence ,
probabilities = probabilities ,
)
```
- [ ] **Step 2: Verify syntax**
Run with the service virtual environment if it exists:
```bash
apps/ml-service/.venv/bin/python -m py_compile apps/ml-service/main.py
```
If no virtual environment was created because dependencies were already installed globally, run:
```bash
python -m py_compile apps/ml-service/main.py
```
Expected: command exits with no output.
- [ ] **Step 3: Commit**
Run:
```bash
git add apps/ml-service/main.py
git commit -m "feat: add ML service API routes"
```
Expected: commit succeeds.
---
### Task 5: Add service documentation
**Files:**
- Create: `apps/ml-service/README.md`
- [ ] **Step 1: Write README**
Create `apps/ml-service/README.md` with exactly:
```markdown
# ZeaVis ML Service
FastAPI service for serving corn leaf disease predictions from the trained Keras model.
## Setup
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
The default model path is `../../Machine_Learning/best_model/best_model.keras` . Override it with `MODEL_PATH` if needed.
## Run
```bash
uvicorn main:app --host 0.0.0.0 --port 8001
```
## Endpoints
- `GET /health` — service status and model loaded status.
- `GET /metadata` — service name, version, labels, input size, model path, and model loaded status.
- `POST /predict` — multipart image upload for disease classification.
## Verify
```bash
curl http://localhost:8001/health
curl http://localhost:8001/metadata
curl -X POST http://localhost:8001/predict -F "file=@/path/to/corn-leaf.jpg"
```
```
- [ ] **Step 2: Commit**
Run:
```bash
git add apps/ml-service/README.md
git commit -m "docs: add ML service usage guide"
```
Expected: commit succeeds.
---
### Task 6: Verify service behavior
**Files:**
- No code changes expected.
- [ ] **Step 1: Install dependencies if needed**
If `apps/ml-service/.venv` does not exist, run:
```bash
python -m venv apps/ml-service/.venv
apps/ml-service/.venv/bin/pip install -r apps/ml-service/requirements.txt
```
Expected: dependencies install successfully.
- [ ] **Step 2: Start the service**
Run:
```bash
cd apps/ml-service && .venv/bin/uvicorn main:app --host 127.0.0.1 --port 8001
```
Expected: Uvicorn starts and logs application startup. If `Machine_Learning/best_model/best_model.keras` is missing, the service should still start.
- [ ] **Step 3: Verify health endpoint**
In a second terminal, run:
```bash
curl -s http://127.0.0.1:8001/health
```
Expected when model is missing:
```json
{ "status" : "ok" , "model_loaded" : false }
```
Expected when model exists and loads:
```json
{ "status" : "ok" , "model_loaded" : true }
```
- [ ] **Step 4: Verify metadata endpoint**
Run:
```bash
curl -s http://127.0.0.1:8001/metadata
```
Expected: JSON includes these fields and labels:
```json
{
"service_name" : "zeavis-ml-service" ,
"service_version" : "0.1.0" ,
"model_path" : "/absolute/path/to/Machine_Learning/best_model/best_model.keras" ,
"model_loaded" : false ,
"input_size" : 224 ,
"labels" : [ "Bercak Daun" , "Hawar Daun" , "Karat Daun" , "Daun Sehat" ]
}
```
The exact absolute `model_path` value depends on the local checkout path.
- [ ] **Step 5: Verify predict unavailable when model is missing**
Run this only if `Machine_Learning/best_model/best_model.keras` is absent:
```bash
python - <<'PY'
from pathlib import Path
from PIL import Image
path = Path('/tmp/zeavis-test-image.jpg')
Image.new('RGB', (224, 224), color='green').save(path)
print(path)
PY
curl -s -o /tmp/zeavis-predict-response.json -w "%{http_code}\n" -X POST http://127.0.0.1:8001/predict -F "file=@/tmp/zeavis-test-image.jpg"
cat /tmp/zeavis-predict-response.json
```
Expected HTTP code: `503`
Expected response:
```json
{ "detail" : "Model is not loaded" }
```
- [ ] **Step 6: Verify predict success when model is present**
Run this only if `Machine_Learning/best_model/best_model.keras` exists:
```bash
curl -s -X POST http://127.0.0.1:8001/predict -F "file=@/path/to/real-corn-leaf-image.jpg"
```
Expected: JSON has `label` , `confidence` , and `probabilities` . `label` must be one of `Bercak Daun` , `Hawar Daun` , `Karat Daun` , or `Daun Sehat` .
- [ ] **Step 7: Stop the service**
Press `Ctrl+C` in the Uvicorn terminal.
Expected: server shuts down cleanly.
---
### Task 7: Final review and branch status
**Files:**
- Review all changed files.
- [ ] **Step 1: Check git status**
Run:
```bash
git status --short
```
Expected: no uncommitted changes if every task committed successfully.
- [ ] **Step 2: Review commit history**
Run:
```bash
git log --oneline -6
```
Expected: recent commits include the ML service configuration, schemas, model service, routes, and README commits.
- [ ] **Step 3: Summarize verification evidence**
Record in the final response:
```text
Verified:
- Python syntax compilation for schemas, model, and main app.
- FastAPI service starts on 127.0.0.1:8001.
- GET /health returns service status and model loaded status.
- GET /metadata returns labels, input size, model path, and model loaded status.
- POST /predict returns 503 when the model artifact is unavailable, or returns prediction JSON when the model artifact and sample image are available.
```
Expected: final response only claims checks that were actually run.
2026-08-02 16:49:11 +07:00
---
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.