Files
zeavis-edu/docs/superpowers/plans/2026-05-22-api-ml-service-migration.md

16 KiB

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:

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:

bun run typecheck

Expected: typecheck exits 0. If unrelated existing typecheck failures appear, record exact failures before continuing.

  • Step 3: Commit

Run:

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:

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:

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:

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<DiseaseLabel, { slug: DiseaseSlug; label: DiseaseLabel }>(
  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<string> {
  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<ClassificationResult> {
  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:

bun test apps/api/src/lib/image-model.test.ts

Expected: PASS, 4 tests pass.

  • Step 3: Run API typecheck

Run from repository root:

bun run typecheck

Expected: typecheck exits 0.

  • Step 4: Commit adapter and tests

Run:

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:

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:

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:

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:

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:

.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:

curl -s http://127.0.0.1:8001/health

Expected when model artifact exists:

{"status":"ok","model_loaded":true}
  • Step 3: Exercise API adapter with a synthetic image

Run from repository root:

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:

git status --short

Expected: no uncommitted changes.

  • Step 2: Run final static checks

Run from repository root:

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:

- 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:

Verified:
- Adapter unit tests: <actual result>
- API typecheck: <actual result>
- Dependency import grep: <actual result>
- ML service health: <actual result if run>
- classifyImage against running ML service: <actual result if run>

Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.