280 lines
9.6 KiB
Markdown
280 lines
9.6 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
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**:
|
|
|
|
```typescript
|
|
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**:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```tsx
|
|
// 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`:
|
|
|
|
```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**:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
export interface IdentifyResponse {
|
|
predictions?: PredictionResult[];
|
|
healthy?: boolean;
|
|
plantId?: string;
|
|
confidence?: ConfidenceResult;
|
|
metadata: InferenceMetadata;
|
|
}
|
|
```
|
|
|
|
10. **Add structured logging** for inference requests:
|
|
```typescript
|
|
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
|