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/identifyendpoint - 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_modefallback path - Handle healthy class predictions correctly (return "no disease detected" message)
deliverables:
src/app/api/identify/route.ts— rewritten to use real model inferencesrc/lib/ml/inference.ts— updated to use calibration and return structured resultssrc/lib/api/identify.ts— client-side API updated for new response shapesrc/components/ResultsDashboard.tsx— handle healthy predictions and remove demo mode badgesrc/components/HealthyResult.tsx— new component for "no disease detected" state
steps:
-
Rewrite
/api/identifyroute 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 }); } -
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; } -
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; } -
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 } -
Update
ResultsDashboardcomponent 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} />; } -
Create
HealthyResultcomponentsrc/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> ); } -
Remove demo mode logic:
- In
model-loader.ts: removecreateMockModel()fallback (or keep it but only for development) - In
route.ts: removedemo_mode: truebranch - In
ResultsDashboard.tsx: remove "Demo mode" badge - In
src/lib/api/identify.ts: removedemo_modefrom response type
- In
-
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 }, ); } -
Update client-side API
src/lib/api/identify.ts:export interface IdentifyResponse { predictions?: PredictionResult[]; healthy?: boolean; plantId?: string; confidence?: ConfidenceResult; metadata: InferenceMetadata; } -
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/identifywith valid imageId returns real predictions (nodemo_mode: true) - Integration: response includes
predictionsarray with valid diseaseIds from KB - Integration: confidence scores are calibrated (not raw softmax)
- Integration: healthy predictions return
healthy: truewith 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/identifyreturns 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.tsnpx vitest run src/lib/ml/inference.test.tscurl -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_modefield can be removed entirely or kept asfalsefor backwards compatibility