# API ML Service Migration 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:** Migrate `apps/api` image classification from local TensorFlow.js inference to HTTP calls to `apps/ml-service` while preserving the existing `classifyImage(file)` contract. **Architecture:** `apps/api/src/lib/image-model.ts` remains the only API-side inference boundary, but becomes a small HTTP adapter. API routes continue calling `classifyImage(file)` unchanged, while `apps/api/src/config/env.ts` provides `mlServiceUrl` from `ML_SERVICE_URL` with a local default. TFJS and image decoder dependencies are removed from the API package after the adapter no longer imports them. **Tech Stack:** Bun, TypeScript, Elysia, built-in `fetch`/`FormData`, Moon typecheck, FastAPI ML service. --- ## File Structure Modify these files: - `apps/api/src/config/env.ts` — add `mlServiceUrl` with default `http://127.0.0.1:8001`. - `apps/api/src/lib/image-model.ts` — replace TFJS local inference with HTTP adapter logic. - `apps/api/package.json` — remove local inference dependencies `@tensorflow/tfjs`, `jpeg-js`, and `pngjs`. - `bun.lock` — update automatically after dependency removal. Create these files: - `apps/api/src/lib/image-model.test.ts` — Bun tests for API-side ML service response mapping and failure behavior. Do not modify these files: - `apps/api/src/routes/classifications.ts` — existing route behavior should remain stable. - `apps/api/src/routes/diagnoses.ts` — existing diagnosis behavior should remain stable. - `apps/ml-service/*` — service already exposes the required `/predict` contract. - Database schema files — schema is unchanged. - Frontend files — frontend behavior is unchanged. --- ### Task 1: Add API ML service configuration **Files:** - Modify: `apps/api/src/config/env.ts` - [ ] **Step 1: Update env configuration** Modify `apps/api/src/config/env.ts` so the `env` object includes `mlServiceUrl` after `uploaderBaseUrl`: ```ts const uploadAllowedMimeTypes = (Bun.env.UPLOAD_ALLOWED_MIME_TYPES ?? 'image/jpeg,image/png') .split(',') .map((value) => value.trim()) .filter(Boolean); const googleOAuthEnabled = Boolean( Bun.env.GOOGLE_CLIENT_ID && Bun.env.GOOGLE_CLIENT_SECRET && Bun.env.GOOGLE_REDIRECT_URI, ); const webAppUrl = Bun.env.WEB_APP_URL ?? 'http://localhost:5173'; const secureCookies = Bun.env.SECURE_COOKIES === 'true' || webAppUrl.startsWith('https://'); export const env = { port: Number(Bun.env.API_PORT ?? 3000), databaseUrl: Bun.env.DATABASE_URL, sessionSecret: Bun.env.SESSION_SECRET, uploaderBaseUrl: Bun.env.UPLOADER_BASE_URL ?? 'https://upload.asepharyana.tech', mlServiceUrl: Bun.env.ML_SERVICE_URL ?? 'http://127.0.0.1:8001', uploadMaxBytes: Number(Bun.env.UPLOAD_MAX_BYTES ?? 5 * 1024 * 1024), uploadAllowedMimeTypes, googleOAuthEnabled, googleClientId: Bun.env.GOOGLE_CLIENT_ID, googleClientSecret: Bun.env.GOOGLE_CLIENT_SECRET, googleRedirectUri: Bun.env.GOOGLE_REDIRECT_URI, webAppUrl, secureCookies, }; export function assertRequiredEnv() { if (!env.databaseUrl) { throw new Error('DATABASE_URL is required'); } if (!env.sessionSecret || env.sessionSecret.length < 32) { throw new Error('SESSION_SECRET is required and must be at least 32 characters'); } } ``` - [ ] **Step 2: Run API typecheck** Run from repository root: ```bash bun run typecheck ``` Expected: typecheck exits 0. If unrelated existing typecheck failures appear, record exact failures before continuing. - [ ] **Step 3: Commit** Run: ```bash git add apps/api/src/config/env.ts git commit -m "feat: add ML service URL config" ``` Expected: commit succeeds. --- ### Task 2: Add failing tests for ML service adapter behavior **Files:** - Create: `apps/api/src/lib/image-model.test.ts` - [ ] **Step 1: Write tests before changing adapter implementation** Create `apps/api/src/lib/image-model.test.ts` with exactly: ```ts import { afterEach, describe, expect, test } from 'bun:test'; import { classifyImage } from './image-model'; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); function makeImageFile(type = 'image/jpeg') { return new File([new Uint8Array([1, 2, 3])], 'leaf.jpg', { type }); } describe('classifyImage', () => { test('maps ML service prediction response to API classification result', async () => { globalThis.fetch = async (input, init) => { expect(String(input)).toBe('http://127.0.0.1:8001/predict'); expect(init?.method).toBe('POST'); expect(init?.body).toBeInstanceOf(FormData); return new Response( JSON.stringify({ label: 'Daun Sehat', confidence: 0.92, probabilities: { 'Bercak Daun': 0.02, 'Daun Sehat': 0.92, 'Karat Daun': 0.03, 'Hawar Daun': 0.03, }, }), { status: 200, headers: { 'content-type': 'application/json' } }, ); }; const result = await classifyImage(makeImageFile()); expect(result.predictedDiseaseSlug).toBe('daun-sehat'); expect(result.confidence).toBe(0.92); expect(result.probabilities).toEqual([ { diseaseSlug: 'daun-sehat', label: 'Daun Sehat', confidence: 0.92 }, { diseaseSlug: 'karat-daun', label: 'Karat Daun', confidence: 0.03 }, { diseaseSlug: 'hawar-daun', label: 'Hawar Daun', confidence: 0.03 }, { diseaseSlug: 'bercak-daun', label: 'Bercak Daun', confidence: 0.02 }, ]); }); test('rejects unsupported file types before calling ML service', async () => { let called = false; globalThis.fetch = async () => { called = true; return new Response('{}'); }; await expect(classifyImage(makeImageFile('image/webp'))).rejects.toThrow('File must be JPEG or PNG'); expect(called).toBe(false); }); test('throws when ML service returns a non-success response', async () => { globalThis.fetch = async () => new Response(JSON.stringify({ detail: 'Model is not loaded' }), { status: 503 }); await expect(classifyImage(makeImageFile())).rejects.toThrow('ML service returned 503: Model is not loaded'); }); test('throws when ML service returns an unknown label', async () => { globalThis.fetch = async () => new Response( JSON.stringify({ label: 'Unknown Disease', confidence: 0.7, probabilities: { 'Unknown Disease': 0.7 }, }), { status: 200, headers: { 'content-type': 'application/json' } }, ); await expect(classifyImage(makeImageFile())).rejects.toThrow('Unknown ML service label: Unknown Disease'); }); }); ``` - [ ] **Step 2: Run tests to verify they fail against existing TFJS implementation** Run from repository root: ```bash bun test apps/api/src/lib/image-model.test.ts ``` Expected: FAIL because the current implementation tries to load the local TFJS model or otherwise does not call `fetch` with `http://127.0.0.1:8001/predict`. - [ ] **Step 3: Commit failing tests only if repository policy allows red commits** Do not commit the failing test by itself in this repo. Continue to Task 3 and commit tests with the implementation once green. --- ### Task 3: Replace local TFJS inference with ML service HTTP adapter **Files:** - Modify: `apps/api/src/lib/image-model.ts` - Test: `apps/api/src/lib/image-model.test.ts` - [ ] **Step 1: Replace image-model implementation** Replace all contents of `apps/api/src/lib/image-model.ts` with exactly: ```ts import type { DiseaseSlug, DiseaseLabel, PredictionProbability } from '@zeavis/shared'; import { env } from '../config/env'; const DISEASE_CLASSES: Array<{ slug: DiseaseSlug; label: DiseaseLabel }> = [ { slug: 'bercak-daun', label: 'Bercak Daun' }, { slug: 'daun-sehat', label: 'Daun Sehat' }, { slug: 'karat-daun', label: 'Karat Daun' }, { slug: 'hawar-daun', label: 'Hawar Daun' }, ]; const DISEASE_BY_LABEL = new Map( DISEASE_CLASSES.map((disease) => [disease.label, disease]), ); type MlPredictionResponse = { label: unknown; confidence: unknown; probabilities: unknown; }; export type ClassificationResult = { predictedDiseaseSlug: DiseaseSlug; confidence: number; probabilities: PredictionProbability[]; }; function predictUrl() { return `${env.mlServiceUrl.replace(/\/+$/, '')}/predict`; } function assertKnownLabel(label: unknown): DiseaseLabel { if (typeof label !== 'string' || !DISEASE_BY_LABEL.has(label as DiseaseLabel)) { throw new Error(`Unknown ML service label: ${String(label)}`); } return label as DiseaseLabel; } function assertConfidence(value: unknown, label: string): number { if (typeof value !== 'number' || !Number.isFinite(value)) { throw new Error(`Invalid ML service confidence for ${label}`); } return Math.max(0, Math.min(1, value)); } function mapProbabilities(probabilities: unknown): PredictionProbability[] { if (!probabilities || typeof probabilities !== 'object' || Array.isArray(probabilities)) { throw new Error('Invalid ML service probabilities'); } return Object.entries(probabilities).map(([label, confidence]) => { const knownLabel = assertKnownLabel(label); const disease = DISEASE_BY_LABEL.get(knownLabel)!; return { diseaseSlug: disease.slug, label: disease.label, confidence: assertConfidence(confidence, disease.label), }; }).sort((a, b) => b.confidence - a.confidence); } async function parseErrorResponse(response: Response): Promise { try { const body = await response.json(); if (body && typeof body === 'object' && 'detail' in body) { return String(body.detail); } } catch { return response.statusText || 'Unknown error'; } return response.statusText || 'Unknown error'; } export async function classifyImage(file: File): Promise { if (file.type !== 'image/jpeg' && file.type !== 'image/png') { throw new Error('File must be JPEG or PNG'); } const formData = new FormData(); formData.append('file', file, file.name || 'leaf-image'); let response: Response; try { response = await fetch(predictUrl(), { method: 'POST', body: formData, }); } catch (error) { throw new Error(`ML service request failed: ${error instanceof Error ? error.message : String(error)}`); } if (!response.ok) { const message = await parseErrorResponse(response); throw new Error(`ML service returned ${response.status}: ${message}`); } const prediction = await response.json() as MlPredictionResponse; const predictedLabel = assertKnownLabel(prediction.label); const predictedDisease = DISEASE_BY_LABEL.get(predictedLabel)!; return { predictedDiseaseSlug: predictedDisease.slug, confidence: assertConfidence(prediction.confidence, predictedDisease.label), probabilities: mapProbabilities(prediction.probabilities), }; } ``` - [ ] **Step 2: Run adapter tests to verify green** Run from repository root: ```bash bun test apps/api/src/lib/image-model.test.ts ``` Expected: PASS, 4 tests pass. - [ ] **Step 3: Run API typecheck** Run from repository root: ```bash bun run typecheck ``` Expected: typecheck exits 0. - [ ] **Step 4: Commit adapter and tests** Run: ```bash git add apps/api/src/lib/image-model.ts apps/api/src/lib/image-model.test.ts git commit -m "feat: call ML service for image classification" ``` Expected: commit succeeds. --- ### Task 4: Remove local TFJS dependencies from API **Files:** - Modify: `apps/api/package.json` - Modify: `bun.lock` - [ ] **Step 1: Remove unused dependencies** Run from repository root: ```bash bun remove --cwd apps/api @tensorflow/tfjs jpeg-js pngjs ``` Expected: `apps/api/package.json` no longer lists `@tensorflow/tfjs`, `jpeg-js`, or `pngjs`, and `bun.lock` updates. - [ ] **Step 2: Confirm no local inference imports remain in API source** Run from repository root: ```bash grep -R "@tensorflow/tfjs\|jpeg-js\|pngjs" -n apps/api/src apps/api/package.json || true ``` Expected: no output. - [ ] **Step 3: Run tests and typecheck** Run from repository root: ```bash bun test apps/api/src/lib/image-model.test.ts bun run typecheck ``` Expected: adapter tests pass and typecheck exits 0. - [ ] **Step 4: Commit dependency cleanup** Run: ```bash git add apps/api/package.json bun.lock git commit -m "chore: remove API local model dependencies" ``` Expected: commit succeeds. --- ### Task 5: Verify API adapter against running ML service **Files:** - No production code changes expected. - [ ] **Step 1: Start ML service** Run from `apps/ml-service`: ```bash .venv/bin/uvicorn main:app --host 127.0.0.1 --port 8001 ``` Expected: service starts. If `Machine_Learning/best_model/best_model.keras` exists, startup loads the model. - [ ] **Step 2: Confirm ML service health** Run from another terminal: ```bash curl -s http://127.0.0.1:8001/health ``` Expected when model artifact exists: ```json {"status":"ok","model_loaded":true} ``` - [ ] **Step 3: Exercise API adapter with a synthetic image** Run from repository root: ```bash bun --cwd apps/api --env-file=../../.env - <<'TS' import { classifyImage } from './src/lib/image-model'; const bytes = new Uint8Array([ 255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 1, 1, 0, 72, 0, 72, 0, 0, 255, 219, 0, 67, 0, 8, 6, 6, 7, 6, 5, 8, 7, 7, 7, 9, 9, 8, 10, 12, 20, 13, 12, 11, 11, 12, 25, 18, 19, 15, 20, 29, 26, 31, 30, 29, 26, 28, 28, 32, 36, 46, 39, 32, 34, 44, 35, 28, 28, 40, 55, 41, 44, 48, 49, 52, 52, 52, 31, 39, 57, 61, 56, 50, 60, 46, 51, 52, 50, 255, 192, 0, 11, 8, 0, 1, 0, 1, 1, 1, 17, 0, 255, 196, 0, 20, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 196, 0, 20, 16, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 218, 0, 8, 1, 1, 0, 0, 63, 0, 127, 255, 217, ]); const file = new File([bytes], 'synthetic.jpg', { type: 'image/jpeg' }); const result = await classifyImage(file); console.log(JSON.stringify(result)); TS ``` Expected: JSON includes `predictedDiseaseSlug`, `confidence`, and `probabilities`. `predictedDiseaseSlug` is one of `bercak-daun`, `daun-sehat`, `karat-daun`, or `hawar-daun`. - [ ] **Step 4: Stop ML service** Press `Ctrl+C` in the ML service terminal. Expected: server shuts down cleanly. --- ### Task 6: Final verification and review **Files:** - Review all changed files. - [ ] **Step 1: Run final status check** Run from repository root: ```bash git status --short ``` Expected: no uncommitted changes. - [ ] **Step 2: Run final static checks** Run from repository root: ```bash bun test apps/api/src/lib/image-model.test.ts bun run typecheck grep -R "@tensorflow/tfjs\|jpeg-js\|pngjs" -n apps/api/src apps/api/package.json || true ``` Expected: tests pass, typecheck exits 0, grep has no output. - [ ] **Step 3: Request final code review** Review range starts at commit before the API migration spec and ends at current HEAD. Ask reviewer to check: ```text - API classifyImage still returns predictedDiseaseSlug, confidence, probabilities. - Error behavior preserves existing route semantics. - ML service URL config is safe and trims trailing slashes. - Removed dependencies are not used anywhere under apps/api/src. - No frontend or database schema changes were introduced. ``` Expected: reviewer reports no Critical or Important issues. - [ ] **Step 4: Summarize actual verification evidence** Final response must include only checks that were actually run: ```text Verified: - Adapter unit tests: - API typecheck: - Dependency import grep: - ML service health: - classifyImage against running ML service: ``` --- > Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.