Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ def _row_to_payload(row: dict) -> dict:
"confidence": round((row.get("confidence_score") or 0) * 100, 1),
"classification": "FRESH" if is_fresh else "SPOILED",
"is_fresh": is_fresh,
"uncertain_flag": False,
"uncertain_flag": (row.get("confidence_score") or 1.0) < 0.70,
"species": {
"common_name": "Rohu Carp",
"scientific_name": "Labeo rohita",
Expand Down Expand Up @@ -528,12 +528,59 @@ async def process_scan(
async def scan_auto(
request: Request,
image: UploadFile = File(...),
freshness_label: Optional[str] = Form(None),
fused_score: Optional[float] = Form(None),
source: Optional[str] = Form(None),
confidence_score: Optional[float] = Form(None),
species_detected: Optional[str] = Form(None),
current_user=Depends(get_current_user),
):
image_bytes = await image.read()
scan_id = str(uuid.uuid4())
display_id = _generate_display_id()

# If edge_onnx path is used, save directly and bypass server inference
if source == "edge_onnx" and fused_score is not None:
freshness = int(fused_score * 100)
conf = confidence_score or 0.85
edge_fusion = {
"final_score_percent": freshness,
"final_grade": _to_db_grade(freshness_label or "C"),
"confidence_score": conf,
"uncertain_prediction_flag": conf < 0.70,
Comment on lines +543 to +550

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Validate client-supplied edge fields; falsy confidence_score collapses to the default.

conf = confidence_score or 0.85 treats a legitimate 0.0 (falsy) as 0.85, masking a genuinely low-confidence scan. Also, fused_score is trusted unbounded: int(fused_score * 100) and regional_breakdown feed straight into the DB, so a malformed/malicious client could persist a freshness_index outside [0, 100]. Prefer explicit None checks and clamp inputs.

🛡️ Proposed fix
-        freshness = int(fused_score * 100)
-        conf = confidence_score or 0.85
+        fused_score = min(max(fused_score, 0.0), 1.0)
+        freshness = int(fused_score * 100)
+        conf = 0.85 if confidence_score is None else min(max(confidence_score, 0.0), 1.0)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if source == "edge_onnx" and fused_score is not None:
freshness = int(fused_score * 100)
conf = confidence_score or 0.85
edge_fusion = {
"final_score_percent": freshness,
"final_grade": _to_db_grade(freshness_label or "C"),
"confidence_score": conf,
"uncertain_prediction_flag": conf < 0.70,
if source == "edge_onnx" and fused_score is not None:
fused_score = min(max(fused_score, 0.0), 1.0)
freshness = int(fused_score * 100)
conf = 0.85 if confidence_score is None else min(max(confidence_score, 0.0), 1.0)
edge_fusion = {
"final_score_percent": freshness,
"final_grade": _to_db_grade(freshness_label or "C"),
"confidence_score": conf,
"uncertain_prediction_flag": conf < 0.70,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/main.py` around lines 543 - 550, The edge ONNX fusion path in main.py
is treating client values too loosely: `confidence_score` should use an explicit
`None` check so a valid `0.0` is preserved instead of falling back to `0.85`,
and `fused_score` should be validated/clamped before converting to
`final_score_percent` and storing related fields. Update the `source ==
"edge_onnx"` block to sanitize `confidence_score`, `fused_score`, and any
`regional_breakdown`-derived values before building `edge_fusion`, keeping
persisted scores within the expected `[0, 100]` range.

"regional_breakdown": {
"gill_freshness_score": fused_score,
"eye_freshness_score": fused_score,
"body_freshness_score": fused_score,
},
}
photo_url = await _upload_image(image_bytes, str(current_user.id), scan_id)
payload = _build_scan_payload(edge_fusion, scan_id, display_id, photo_url)
if species_detected:
payload["species"]["common_name"] = species_detected

try:
_db().table("scans").insert(
{
"id": scan_id,
"user_id": str(current_user.id),
"final_grade": _to_db_grade(payload["grade"]),
"confidence_score": conf,
"image_type": "BODY",
"freshness_index": payload["freshness_index"],
"scan_display_id": display_id,
"species_detected": species_detected or "Rohu Carp",
"biomarker_json": payload["biomarkers"],
"storage_hours": payload["recommendations"]["consume_within_hours"],
"alert_flags": payload["recommendations"]["alert_flags"],
"photo_urls": [photo_url] if photo_url else [],
}
).execute()
except Exception as exc:
print(f"DB write failed (edge_onnx): {exc}")

return {"success": True, "scan": payload}
Comment on lines +562 to +582

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Silent DB-write failure returns success: True, causing offline data loss.

The insert(...) is wrapped in a try/except that merely prints and falls through to return {"success": True, "scan": payload}. The offline sync loop in src/pages/ScannerPage.tsx (checkAndSyncScans) deletes the locally queued scan (offlineDb.deleteScan) on any successful response. So if the Supabase insert throws, the client discards the only copy of the scan → permanent loss. The edge_onnx path must propagate the failure (e.g., non-2xx / success: False) so the queued record is retained for retry.

🛡️ Proposed fix
         try:
             _db().table("scans").insert(
                 {
                     ...
                 }
             ).execute()
         except Exception as exc:
             print(f"DB write failed (edge_onnx): {exc}")
+            raise HTTPException(status_code=502, detail="Failed to persist edge scan; retry later.")

         return {"success": True, "scan": payload}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
_db().table("scans").insert(
{
"id": scan_id,
"user_id": str(current_user.id),
"final_grade": _to_db_grade(payload["grade"]),
"confidence_score": conf,
"image_type": "BODY",
"freshness_index": payload["freshness_index"],
"scan_display_id": display_id,
"species_detected": species_detected or "Rohu Carp",
"biomarker_json": payload["biomarkers"],
"storage_hours": payload["recommendations"]["consume_within_hours"],
"alert_flags": payload["recommendations"]["alert_flags"],
"photo_urls": [photo_url] if photo_url else [],
}
).execute()
except Exception as exc:
print(f"DB write failed (edge_onnx): {exc}")
return {"success": True, "scan": payload}
try:
_db().table("scans").insert(
{
"id": scan_id,
"user_id": str(current_user.id),
"final_grade": _to_db_grade(payload["grade"]),
"confidence_score": conf,
"image_type": "BODY",
"freshness_index": payload["freshness_index"],
"scan_display_id": display_id,
"species_detected": species_detected or "Rohu Carp",
"biomarker_json": payload["biomarkers"],
"storage_hours": payload["recommendations"]["consume_within_hours"],
"alert_flags": payload["recommendations"]["alert_flags"],
"photo_urls": [photo_url] if photo_url else [],
}
).execute()
except Exception as exc:
print(f"DB write failed (edge_onnx): {exc}")
raise HTTPException(status_code=502, detail="Failed to persist edge scan; retry later.")
return {"success": True, "scan": payload}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/main.py` around lines 562 - 582, The scan insert path in the
`edge_onnx` handler is swallowing database errors and still returning success,
which causes the offline sync flow to delete the queued scan. Update the
`try/except` around `_db().table("scans").insert(...).execute()` so failures are
propagated back to the client as a non-success response from this handler, and
only return the success payload when the insert actually completes. Make sure
the response contract used by `checkAndSyncScans` in `ScannerPage.tsx` can
distinguish failure so `offlineDb.deleteScan` is not called on a failed write.


# ── Demo mode: models not loaded (PyTorch not installed) ─────────────────
if not _models_loaded:
gill = random.randint(68, 96)
Expand Down
15 changes: 14 additions & 1 deletion src/fusionInference.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,15 @@ function extractGillScore(logitsB, temperature) {
* @param {number[]} gillProbs [P(Fresh_Gills), P(Nonfresh_Gills)]
* @returns {{ fusedScore: number, label: string, confidence: string }}
*/
function calculateConfidence(bodyProbs, eyeProbs, gillProbs) {
const bodyConf = Math.max(...bodyProbs);
const eyeSubSum = (eyeProbs[0] + eyeProbs[2]) || 1e-7;
const gillSubSum = (gillProbs[1] + gillProbs[3]) || 1e-7;
const eyeConf = Math.max(eyeProbs[0] / eyeSubSum, eyeProbs[2] / eyeSubSum);
const gillConf = Math.max(gillProbs[1] / gillSubSum, gillProbs[3] / gillSubSum);
return (0.5 * bodyConf) + (0.25 * eyeConf) + (0.25 * gillConf);
}

function processAndFuse(bodyProbs, eyeProbs, gillProbs) {
const bodyFresh = bodyProbs[0]; // P(C1 = Fresh)
const eyeFresh = eyeProbs[0]; // P(Fresh_Eyes)
Expand All @@ -274,10 +283,14 @@ function processAndFuse(bodyProbs, eyeProbs, gillProbs) {
label = 'Spoiled';
}

const systemConfidence = calculateConfidence(bodyProbs, eyeProbs, gillProbs);
const isUncertain = systemConfidence < 0.70;

return {
fusedScore,
label,
confidence: (fusedScore * 100).toFixed(1) + '%',
confidence: systemConfidence,
uncertain_flag: isUncertain
};
}

Expand Down
13 changes: 11 additions & 2 deletions src/i18n/locales/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@
"notFishDetected": "মাছ নয়: কোনো মাছ সনাক্ত করা হয়নি। একটি মাছের ছবি আপলোড করুন।",
"inferenceFailed": "অনুমান ব্যর্থ।",
"capturedAlt": "ক্যাপচার করা হয়েছে",
"rejectedUploadAlt": "আপলোড প্রত্যাখ্যাত করা হয়েছে"
"rejectedUploadAlt": "আপলোড প্রত্যাখ্যাত করা হয়েছে",
"syncingScans": "ব্যাকগ্রাউন্ডে অফলাইন স্ক্যান সিঙ্ক হচ্ছে...",
"syncSuccess": "অফলাইন স্ক্যানগুলি সফলভাবে সিঙ্ক হয়েছে!",
"savedOffline": "অফলাইন মোডে স্থানীয়ভাবে স্ক্যান সংরক্ষিত হয়েছে",
"pendingSyncScans": "অফলাইন স্ক্যান সিঙ্ক পেন্ডিং রয়েছে",
"syncNowButton": "এখন সিঙ্ক করুন"
},
"dashboard": {
"loadingAnalysis": "বিশ্লেষণ লোড হচ্ছে...",
Expand Down Expand Up @@ -144,7 +149,11 @@
"storageTemp": "সঞ্চয় তাপমাত্রা",
"alertLabel": "সতর্কতা",
"newScanButton": "নতুন স্ক্যান",
"viewHistoryButton": "ইতিহাস দেখুন"
"viewHistoryButton": "ইতিহাস দেখুন",
"uncertainWarningTitle": "এআই পূর্বাভাস অনিশ্চিত",
"uncertainWarningDesc": "মডেলটি ইনপুট মানের মধ্যে উচ্চ বৈচিত্র্য সনাক্ত করেছে (যেমন আলোর ছায়া বা বন্ধ কোণ)। তাজা সূচক স্বাভাবিকের চেয়ে কম নির্ভরযোগ্য হতে পারে।",
"suggestRescan": "→ নমুনাটি পুনরায় স্ক্যান করার পরামর্শ দিন",
"uncertaintyMargin": "ত্রুটি মার্জিন:"
},
"auth": {
"authInitiated": "প্রমাণীকরণ শুরু করা হয়েছে",
Expand Down
13 changes: 11 additions & 2 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@
"notFishDetected": "NOT A FISH: No fish detected. Please photograph a fish.",
"inferenceFailed": "Inference failed.",
"capturedAlt": "Captured",
"rejectedUploadAlt": "Rejected upload"
"rejectedUploadAlt": "Rejected upload",
"syncingScans": "Syncing offline scans in background...",
"syncSuccess": "Successfully synchronized offline scans!",
Comment on lines +112 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the sync count in the localized success message.

ScannerPage.tsx already formats this toast with successCount, but the new locale string is fixed text. That drops the number of synced scans for localized users and weakens the progress signal. Please add a count placeholder here and mirror it in the other locale files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/i18n/locales/en.json` around lines 112 - 113, The localized sync success
message is currently fixed text and drops the synced count shown by
ScannerPage.tsx. Update the syncSuccess string in the locale JSON to use a count
placeholder, then make ScannerPage.tsx pass its existing successCount into that
localized message and mirror the same placeholder shape in the other locale
files.

"savedOffline": "Saved scan locally (offline mode)",
"pendingSyncScans": "OFFLINE SCANS PENDING SYNC",
"syncNowButton": "SYNC NOW"
},
"dashboard": {
"loadingAnalysis": "LOADING ANALYSIS...",
Expand Down Expand Up @@ -144,7 +149,11 @@
"storageTemp": "STORAGE TEMP",
"alertLabel": "ALERT",
"newScanButton": "NEW SCAN",
"viewHistoryButton": "VIEW HISTORY"
"viewHistoryButton": "VIEW HISTORY",
"uncertainWarningTitle": "AI Prediction Uncertain",
"uncertainWarningDesc": "The model detected high variance in input quality (e.g. lighting shadows or off-angles). The freshness index might be less reliable than usual.",
"suggestRescan": "→ Suggest Rescanning specimen",
"uncertaintyMargin": "Margin of Error:"
},
"auth": {
"authInitiated": "AUTH INITIATED",
Expand Down
13 changes: 11 additions & 2 deletions src/i18n/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@
"notFishDetected": "मछली नहीं: कोई मछली नहीं मिली। कृपया एक मछली की तस्वीर अपलोड करें।",
"inferenceFailed": "अनुमान विफल।",
"capturedAlt": "कैप्चर किया गया",
"rejectedUploadAlt": "अपलोड अस्वीकार किया गया"
"rejectedUploadAlt": "अपलोड अस्वीकार किया गया",
"syncingScans": "पृष्ठभूमि में ऑफ़लाइन स्कैन सिंक किए जा रहे हैं...",
"syncSuccess": "ऑफ़लाइन स्कैन सफलतापूर्वक सिंक किए गए!",
"savedOffline": "ऑफ़लाइन मोड में स्थानीय रूप से स्कैन सहेजा गया",
"pendingSyncScans": "ऑफ़लाइन स्कैन सिंक होना बाकी है",
"syncNowButton": "अभी सिंक करें"
},
"dashboard": {
"loadingAnalysis": "विश्लेषण लोड हो रहा है...",
Expand Down Expand Up @@ -144,7 +149,11 @@
"storageTemp": "भंडारण तापमान",
"alertLabel": "सतर्कता",
"newScanButton": "नया स्कैन",
"viewHistoryButton": "इतिहास देखें"
"viewHistoryButton": "इतिहास देखें",
"uncertainWarningTitle": "एआई भविष्यवाणी अनिश्चित",
"uncertainWarningDesc": "मॉडल ने इनपुट गुणवत्ता में उच्च भिन्नता का पता लगाया (जैसे प्रकाश छाया या बंद कोण)। ताजगी सूचकांक सामान्य से कम विश्वसनीय हो सकता है।",
"suggestRescan": "→ नमूने को फिर से स्कैन करने का सुझाव दें",
"uncertaintyMargin": "त्रुटि मार्जिन:"
},
"auth": {
"authInitiated": "प्रमाणीकरण शुरू किया गया",
Expand Down
8 changes: 6 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,12 @@ export interface GradcamResponse {
mode: "real" | "demo";
}

// Metadata sent alongside edge-inference results so the backend can store them
// without re-running the ML pipeline on the server.
export interface EdgeInferenceMeta {
freshness_label?: string;
fused_score?: number;
source?: "edge_onnx" | "server";
confidence_score?: number;
species_detected?: string;
}

// ── API surface ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -165,6 +165,10 @@ export const api = {
if (meta?.fused_score !== undefined)
form.append("fused_score", String(meta.fused_score));
if (meta?.source) form.append("source", meta.source);
if (meta?.confidence_score !== undefined)
form.append("confidence_score", String(meta.confidence_score));
if (meta?.species_detected)
form.append("species_detected", meta.species_detected);

const validRes = await safeFetch(
`${API_BASE}/api/v1/scan-auto`,
Expand Down
106 changes: 106 additions & 0 deletions src/lib/offlineDb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Simple offline IndexedDB manager for FreshScan AI scans queue
// Zero external dependencies to prevent compilation or bundle size overhead

const DB_NAME = 'freshscan_offline_db';
const DB_VERSION = 1;
const STORE_NAME = 'scans_queue';

export interface OfflineScan {
id: string;
image: Blob;
metadata: {
freshness_index: number;
grade: string;
label: string;
confidence: number;
timestamp: string;
species_detected: string;
};
status: 'pending' | 'synced' | 'failed';
error?: string;
}

function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);

request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};

request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result);
};

request.onerror = (event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
}
Comment on lines +23 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

IndexedDB connections are opened per call and never closed.

Every offlineDb.* method calls openDB(), which creates a fresh IDBDatabase connection that is never close()d. Under the sync loop (one open per scan in getPendingScans/submitScan/deleteScan) these accumulate, and any future DB_VERSION bump will be blocked by the still-open connections (onupgradeneeded/versionchange never fires). Consider caching a single connection or closing it after each transaction completes, and handling the blocked event.

♻️ One option: close on transaction completion
   async addScan(scan: OfflineScan): Promise<void> {
     const db = await openDB();
     return new Promise((resolve, reject) => {
       const transaction = db.transaction(STORE_NAME, 'readwrite');
       const store = transaction.objectStore(STORE_NAME);
       const request = store.put(scan);
 
-      request.onsuccess = () => resolve();
-      request.onerror = () => reject(request.error);
+      request.onsuccess = () => resolve();
+      request.onerror = () => reject(request.error);
+      transaction.oncomplete = () => db.close();
+      transaction.onabort = () => { db.close(); reject(transaction.error); };
     });
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/offlineDb.ts` around lines 23 - 42, The openDB() helper in offlineDb
opens a new IndexedDB connection on every call and never closes it, so update
the offlineDb access pattern to reuse a cached IDBDatabase or explicitly close
each connection after the transaction finishes. Make the change in openDB() and
the offlineDb methods that call it (such as getPendingScans, submitScan, and
deleteScan) so connections are not left open across the sync loop. Also handle
the IndexedDB blocked/versionchange path by wiring the blocked event and closing
stale connections when a newer DB_VERSION is requested.


export const offlineDb = {
async addScan(scan: OfflineScan): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.put(scan);

request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
},

async getPendingScans(): Promise<OfflineScan[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();

request.onsuccess = () => {
const scans = request.result as OfflineScan[];
resolve(scans.filter(s => s.status === 'pending' || s.status === 'failed'));
};
request.onerror = () => reject(request.error);
});
},

async updateScanStatus(id: string, status: OfflineScan['status'], error?: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);

const getReq = store.get(id);
getReq.onsuccess = () => {
const data = getReq.result as OfflineScan;
if (data) {
data.status = status;
if (error) data.error = error;
const updateReq = store.put(data);
updateReq.onsuccess = () => resolve();
updateReq.onerror = () => reject(updateReq.error);
} else {
resolve();
}
};
getReq.onerror = () => reject(getReq.error);
});
},

async deleteScan(id: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(id);

request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
};
Loading
Loading