Files
plant-disease-id/apps/web/tasks/production-ml-pipeline/05-pipeline-integration.md
2026-06-06 15:09:46 -04:00

9.6 KiB

05. Real Model Integration into Identification Pipeline

meta: id: production-ml-pipeline-05 feature: production-ml-pipeline priority: P0 depends_on: [production-ml-pipeline-02, production-ml-pipeline-03, production-ml-pipeline-04] tags: [implementation, integration, tests-required]

objective:

  • Wire the real TF.js model into the /api/identify endpoint
  • Replace demo/mock predictions with real model inference
  • Use the PlantVillage label mapping (task 02) to resolve class indices to disease IDs
  • Apply confidence calibration (task 04) to produce meaningful confidence scores
  • Remove the demo_mode fallback path
  • Handle healthy class predictions correctly (return "no disease detected" message)

deliverables:

  • src/app/api/identify/route.ts — rewritten to use real model inference
  • src/lib/ml/inference.ts — updated to use calibration and return structured results
  • src/lib/api/identify.ts — client-side API updated for new response shape
  • src/components/ResultsDashboard.tsx — handle healthy predictions and remove demo mode badge
  • src/components/HealthyResult.tsx — new component for "no disease detected" state

steps:

  1. Rewrite /api/identify route handler to use real inference:

    export async function POST(request: NextRequest) {
      // 1. Parse request, validate imageId
      // 2. Load and preprocess image (existing code)
      // 3. Run inference with real model
      const { probabilities, inferenceTimeMs } = await runInference(tensor);
    
      // 4. Calibrate confidence
      const calibrated = calibratePrediction(probabilities, isLogits);
    
      // 5. Map to disease using PlantVillage labels
      const diseaseId = getDiseaseIdForIndex(calibrated.classIndex);
      const isHealthy = isHealthyClass(calibrated.classIndex);
    
      // 6. If healthy, return healthy result
      if (isHealthy && calibrated.adjusted > 0.5) {
        return NextResponse.json({
          healthy: true,
          plantId: getPlantIdForIndex(calibrated.classIndex),
          confidence: calibrated,
          metadata: { model: MODEL_ID, inferenceTimeMs, imageId },
        });
      }
    
      // 7. Get top-K predictions (not just top-1)
      const topK = getTopKFloat32(probabilities, 5);
      const predictions = await enrichPredictions(topK);
    
      // 8. Return results
      return NextResponse.json({
        predictions,
        metadata: { model: MODEL_ID, inferenceTimeMs, imageId },
        demo_mode: false, // or remove this field entirely
      });
    }
    
  2. Update runInference() to return calibrated results:

    export async function runInference(
      imageTensor: Float32Array,
      topK: number = 5,
    ): Promise<InferenceResult> {
      const model = await getModel();
      const modelStatus = model.getStatus();
    
      if (!modelStatus.loaded) {
        throw new Error("Model not loaded. Cannot run inference.");
      }
    
      const { output, inferenceTimeMs } = await model.predict(imageTensor);
    
      // Determine if output is logits or probabilities
      const isLogits = !isProbabilities(output);
    
      // Apply calibration
      const calibration = calibratePrediction(output, isLogits);
    
      // Get top-K predictions
      const probs = isLogits ? temperatureScaledSoftmax(output) : output;
      const topKPredictions = getTopKFloat32(probs, topK);
    
      return {
        predictions: topKPredictions,
        inferenceTimeMs,
        calibration: {
          temperature: PLANTVILLAGE_CALIBRATION.temperature,
          entropy: calibration.entropy,
          entropyConfidence: calibration.entropyConfidence,
        },
      };
    }
    
    function isProbabilities(output: Float32Array): boolean {
      const sum = output.reduce((a, b) => a + b, 0);
      return Math.abs(sum - 1.0) < 0.01;
    }
    
  3. Update enrichPredictions() to use new label mapping:

    async function enrichPredictions(
      topPredictions: Array<{ classIndex: number; probability: number }>,
    ): Promise<PredictionResult[]> {
      const results: PredictionResult[] = [];
    
      for (const pred of topPredictions) {
        // Skip healthy classes in top-K (they're handled separately)
        if (isHealthyClass(pred.classIndex)) continue;
    
        const diseaseId = getDiseaseIdForIndex(pred.classIndex);
        const plantId = getPlantIdForIndex(pred.classIndex);
    
        if (!diseaseId || diseaseId === "healthy") continue;
    
        const disease = await getDiseaseById(diseaseId);
        if (!disease) continue;
    
        // Use probability as raw confidence, calibrate with entropy
        const confidence = calibrateConfidence(pred.probability);
    
        const plant = await getPlantById(disease.plantId).catch(() => null);
    
        results.push({
          diseaseId,
          disease,
          confidence,
          lookalikes: disease.lookalikeDiseaseIds,
          plant: plant ?? null,
        });
      }
    
      results.sort((a, b) => b.confidence.adjusted - a.confidence.adjusted);
      return results;
    }
    
  4. Update response types to support healthy result:

    // src/lib/types.ts
    export interface IdentifyResponse {
      predictions?: PredictionResult[];
      healthy?: boolean;
      plantId?: string;
      confidence?: ConfidenceResult;
      metadata: InferenceMetadata;
      demo_mode?: boolean; // Remove or always false
    }
    
  5. Update ResultsDashboard component to handle healthy result:

    // If response.healthy === true, show HealthyResult component instead of prediction cards
    if (response?.healthy) {
      return <HealthyResult plantId={response.plantId} confidence={response.confidence} />;
    }
    
  6. Create HealthyResult component src/components/HealthyResult.tsx:

    export default function HealthyResult({ plantId, confidence }) {
      const plant = usePlant(plantId); // fetch plant data
      return (
        <div className="...">
          <div className="text-6xl">🌿</div>
          <h2>No Disease Detected</h2>
          <p>
            The image appears healthy{plant ? ` (${plant.commonName})` : ""}. Confidence:{" "}
            {Math.round(confidence.adjusted * 100)}%
          </p>
          <p className="text-sm text-zinc-500">
            If symptoms persist, try uploading a clearer photo of the affected area.
          </p>
        </div>
      );
    }
    
  7. Remove demo mode logic:

    • In model-loader.ts: remove createMockModel() fallback (or keep it but only for development)
    • In route.ts: remove demo_mode: true branch
    • In ResultsDashboard.tsx: remove "Demo mode" badge
    • In src/lib/api/identify.ts: remove demo_mode from response type
  8. Add error handling for model not loaded:

    const model = await getModel();
    if (!model.getStatus().loaded) {
      return NextResponse.json(
        {
          error: "Model not available",
          message: "ML model failed to load. Please try again later.",
        },
        { status: 503 },
      );
    }
    
  9. Update client-side API src/lib/api/identify.ts:

    export interface IdentifyResponse {
      predictions?: PredictionResult[];
      healthy?: boolean;
      plantId?: string;
      confidence?: ConfidenceResult;
      metadata: InferenceMetadata;
    }
    
  10. Add structured logging for inference requests:

    console.log(
      JSON.stringify({
        event: "inference",
        imageId,
        modelId: MODEL_ID,
        inferenceTimeMs,
        topPrediction: predictions[0]?.diseaseId,
        confidence: predictions[0]?.confidence.adjusted,
        entropy: calibration?.entropy,
      }),
    );
    

tests:

  • Integration: POST /api/identify with valid imageId returns real predictions (no demo_mode: true)
  • Integration: response includes predictions array with valid diseaseIds from KB
  • Integration: confidence scores are calibrated (not raw softmax)
  • Integration: healthy predictions return healthy: true with plantId
  • Unit: enrichPredictions() skips healthy classes in top-K
  • Unit: isProbabilities() correctly identifies probability output
  • Unit: runInference() throws error if model not loaded
  • E2E: upload a tomato leaf image → get tomato disease predictions
  • E2E: upload a healthy plant image → get healthy result

acceptance_criteria:

  • /api/identify returns real model predictions (not mock)
  • All diseaseIds in response are valid KB entries (verifiable via getDiseaseById())
  • Confidence scores use temperature-scaled calibration (not raw softmax)
  • Healthy predictions return { healthy: true, plantId, confidence } instead of disease predictions
  • Demo mode is completely removed from production path
  • Error handling: model not loaded → 503 response with clear message
  • Structured logging for every inference request
  • Client-side API handles new response shape (healthy vs predictions)

validation:

  • npx vitest run src/app/api/identify/identify.test.ts
  • npx vitest run src/lib/ml/inference.test.ts
  • curl -X POST http://localhost:3000/api/identify -H "Content-Type: application/json" -d '{"imageId":"<test-id>"}' — response has real predictions
  • Upload a test image via UI → see real disease names (not demo mode)
  • Check server logs: event: "inference" with valid modelId and inferenceTimeMs

notes:

  • This task depends on tasks 02, 03, and 04 being complete. Do not start until all dependencies are met.
  • The enrichPredictions() function now skips healthy classes — they're handled by the healthy result path
  • If the model is not loaded, return 503 (Service Unavailable) instead of falling back to mock
  • Structured logging should be JSON for easy parsing by log aggregators
  • The demo_mode field can be removed entirely or kept as false for backwards compatibility