-
Notifications
You must be signed in to change notification settings - Fork 41
feat(offline): offline scan queue and service worker background sync (#2) #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Silent DB-write failure returns The 🛡️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # ── Demo mode: models not loaded (PyTorch not installed) ───────────────── | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not _models_loaded: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gill = random.randint(68, 96) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
🤖 Prompt for AI Agents |
||
| "savedOffline": "Saved scan locally (offline mode)", | ||
| "pendingSyncScans": "OFFLINE SCANS PENDING SYNC", | ||
| "syncNowButton": "SYNC NOW" | ||
| }, | ||
| "dashboard": { | ||
| "loadingAnalysis": "LOADING ANALYSIS...", | ||
|
|
@@ -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", | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ♻️ 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 |
||
|
|
||
| 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); | ||
| }); | ||
| } | ||
| }; | ||
There was a problem hiding this comment.
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_scorecollapses to the default.conf = confidence_score or 0.85treats a legitimate0.0(falsy) as0.85, masking a genuinely low-confidence scan. Also,fused_scoreis trusted unbounded:int(fused_score * 100)andregional_breakdownfeed straight into the DB, so a malformed/malicious client could persist afreshness_indexoutside[0, 100]. Prefer explicitNonechecks and clamp inputs.🛡️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents