From 24d3e7aef3da42856c525bea3ea190ed52d31311 Mon Sep 17 00:00:00 2001 From: NITISH-R-G <225521762+NITISH-R-G@users.noreply.github.com> Date: Tue, 12 May 2026 04:11:18 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A7=AA=20[Testing]=20Add=20tests=20fo?= =?UTF-8?q?r=20OfflineTriageClassifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- pubspec.lock | 56 +++- test/offline_triage_classifier_test.dart | 407 +++++++++++++++++++++++ 2 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 test/offline_triage_classifier_test.dart diff --git a/pubspec.lock b/pubspec.lock index 57ef972..ae0b56f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + url: "https://pub.dev" + source: hosted + version: "0.1.25" bluez: dependency: transitive description: @@ -1029,6 +1037,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.11.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + url: "https://pub.dev" + source: hosted + version: "0.9.46" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" jwt_decode: dependency: transitive description: @@ -1121,10 +1153,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1722,6 +1754,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.4" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -1734,10 +1774,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timezone: dependency: transitive description: @@ -1746,6 +1786,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" + torch_light: + dependency: "direct main" + description: + name: torch_light + sha256: a1397443a375c6991151547cb77361085df6cf8aa59999292e683db7385a0d15 + url: "https://pub.dev" + source: hosted + version: "1.1.0" typed_data: dependency: transitive description: diff --git a/test/offline_triage_classifier_test.dart b/test/offline_triage_classifier_test.dart new file mode 100644 index 0000000..b809732 --- /dev/null +++ b/test/offline_triage_classifier_test.dart @@ -0,0 +1,407 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:roadsos/services/offline_triage_classifier.dart'; + +void main() { + group('OfflineTriageClassifier', () { + const classifier = OfflineTriageClassifier(); + + group('Severity Estimation', () { + test('level 5 keywords', () { + expect( + classifier + .classify(transcript: 'He is dead', severityHint: 1) + .severityLevel, + 5, + ); + expect( + classifier + .classify(transcript: 'Fatal accident', severityHint: 1) + .severityLevel, + 5, + ); + expect( + classifier + .classify(transcript: 'not breathing', severityHint: 1) + .severityLevel, + 5, + ); + expect( + classifier + .classify(transcript: 'bleeding heavily', severityHint: 1) + .severityLevel, + 5, + ); + expect( + classifier + .classify(transcript: 'unconscious', severityHint: 1) + .severityLevel, + 5, + ); + expect( + classifier + .classify(transcript: 'he is trapped', severityHint: 1) + .severityLevel, + 5, + ); + }); + + test('level 4 keywords', () { + expect( + classifier + .classify(transcript: 'bleeding', severityHint: 1) + .severityLevel, + 4, + ); + expect( + classifier + .classify(transcript: 'broken leg', severityHint: 1) + .severityLevel, + 4, + ); + expect( + classifier + .classify(transcript: 'bone fracture', severityHint: 1) + .severityLevel, + 4, + ); + }); + + test('level 3 keywords', () { + expect( + classifier + .classify(transcript: 'I am hurt', severityHint: 1) + .severityLevel, + 3, + ); + expect( + classifier + .classify(transcript: 'feeling pain', severityHint: 1) + .severityLevel, + 3, + ); + expect( + classifier + .classify(transcript: 'car crash', severityHint: 1) + .severityLevel, + 3, + ); + }); + + test('level 2 keywords', () { + expect( + classifier + .classify(transcript: 'minor issue', severityHint: 1) + .severityLevel, + 2, + ); + expect( + classifier + .classify(transcript: 'just a scratch', severityHint: 1) + .severityLevel, + 2, + ); + expect( + classifier + .classify(transcript: 'a small bump', severityHint: 1) + .severityLevel, + 2, + ); + }); + + test('default severity is 3', () { + expect( + classifier + .classify(transcript: 'hello world', severityHint: 1) + .severityLevel, + 3, + ); + }); + }); + + group('Severity Hint Merging', () { + test('text severity > hint uses text severity', () { + // text = 5 (dead), hint = 2 => 5 + expect( + classifier + .classify(transcript: 'dead', severityHint: 2) + .severityLevel, + 5, + ); + }); + + test('text severity == hint uses text severity', () { + // text = 4 (bleeding), hint = 4 => 4 + expect( + classifier + .classify(transcript: 'bleeding', severityHint: 4) + .severityLevel, + 4, + ); + }); + + test('text severity < hint averages and rounds up', () { + // text = 3 (hurt), hint = 5 => (3 + 5 + 1) ~/ 2 = 4 + expect( + classifier + .classify(transcript: 'hurt', severityHint: 5) + .severityLevel, + 4, + ); + + // text = 2 (minor), hint = 5 => (2 + 5 + 1) ~/ 2 = 4 + expect( + classifier + .classify(transcript: 'minor', severityHint: 5) + .severityLevel, + 4, + ); + + // text = 2 (minor), hint = 3 => (2 + 3 + 1) ~/ 2 = 3 + expect( + classifier + .classify(transcript: 'minor', severityHint: 3) + .severityLevel, + 3, + ); + }); + + test('hint is clamped between 1 and 5', () { + // text = 3 (hurt), hint = 10 => clamped to 5 => (3 + 5 + 1) ~/ 2 = 4 + expect( + classifier + .classify(transcript: 'hurt', severityHint: 10) + .severityLevel, + 4, + ); + + // text = 5 (dead), hint = -10 => clamped to 1 => 5 > 1 ? 5 : ... => 5 + expect( + classifier + .classify(transcript: 'dead', severityHint: -10) + .severityLevel, + 5, + ); + }); + }); + + group('Service Extraction', () { + test('ambulance is always added', () { + final result = classifier.classify( + transcript: 'nothing', + severityHint: 1, + ); + expect(result.requiredServices, contains('ambulance')); + }); + + test('fire department', () { + expect( + classifier + .classify(transcript: 'fire', severityHint: 1) + .requiredServices, + contains('fire_department'), + ); + expect( + classifier + .classify(transcript: 'smoke', severityHint: 1) + .requiredServices, + contains('fire_department'), + ); + expect( + classifier + .classify(transcript: 'burning', severityHint: 1) + .requiredServices, + contains('fire_department'), + ); + }); + + test('police', () { + expect( + classifier + .classify(transcript: 'police', severityHint: 1) + .requiredServices, + contains('police'), + ); + expect( + classifier + .classify(transcript: 'hit and run', severityHint: 1) + .requiredServices, + contains('police'), + ); + expect( + classifier + .classify(transcript: 'drunk driver', severityHint: 1) + .requiredServices, + contains('police'), + ); + }); + + test('rescue', () { + expect( + classifier + .classify(transcript: 'trapped inside', severityHint: 1) + .requiredServices, + contains('rescue'), + ); + expect( + classifier + .classify(transcript: 'stuck', severityHint: 1) + .requiredServices, + contains('rescue'), + ); + expect( + classifier + .classify(transcript: 'need rescue', severityHint: 1) + .requiredServices, + contains('rescue'), + ); + }); + + test('towing', () { + expect( + classifier + .classify(transcript: 'need tow', severityHint: 1) + .requiredServices, + contains('towing'), + ); + expect( + classifier + .classify(transcript: 'towing', severityHint: 1) + .requiredServices, + contains('towing'), + ); + }); + + test('puncture shop', () { + expect( + classifier + .classify(transcript: 'puncture', severityHint: 1) + .requiredServices, + contains('puncture_shop'), + ); + expect( + classifier + .classify(transcript: 'flat tire', severityHint: 1) + .requiredServices, + contains('puncture_shop'), + ); + expect( + classifier + .classify(transcript: 'mechanic', severityHint: 1) + .requiredServices, + contains('puncture_shop'), + ); + }); + + test('showroom', () { + expect( + classifier + .classify(transcript: 'repair', severityHint: 1) + .requiredServices, + contains('showroom'), + ); + expect( + classifier + .classify(transcript: 'spare part', severityHint: 1) + .requiredServices, + contains('showroom'), + ); + expect( + classifier + .classify(transcript: 'showroom', severityHint: 1) + .requiredServices, + contains('showroom'), + ); + }); + + test('multiple services', () { + final result = classifier.classify( + transcript: 'fire and police needed, flat tire', + severityHint: 1, + ); + expect( + result.requiredServices, + containsAll([ + 'ambulance', + 'fire_department', + 'police', + 'puncture_shop', + ]), + ); + }); + }); + + group('First Aid Query Builder', () { + test('bleeding', () { + expect( + classifier + .classify(transcript: 'bleed', severityHint: 1) + .firstAidQuery, + 'severe bleeding wound management tourniquet', + ); + }); + + test('burn', () { + expect( + classifier + .classify(transcript: 'burn', severityHint: 1) + .firstAidQuery, + 'burn wound first aid cool water', + ); + }); + + test('breathing and choking', () { + expect( + classifier + .classify(transcript: 'breath', severityHint: 1) + .firstAidQuery, + 'CPR rescue breathing Heimlich', + ); + expect( + classifier + .classify(transcript: 'chok', severityHint: 1) + .firstAidQuery, + 'CPR rescue breathing Heimlich', + ); + }); + + test('fracture and broken', () { + expect( + classifier + .classify(transcript: 'fracture', severityHint: 1) + .firstAidQuery, + 'fracture immobilization splint', + ); + expect( + classifier + .classify(transcript: 'broken', severityHint: 1) + .firstAidQuery, + 'fracture immobilization splint', + ); + }); + + test('head and concussion', () { + expect( + classifier + .classify(transcript: 'head', severityHint: 1) + .firstAidQuery, + 'head injury concussion protocol', + ); + expect( + classifier + .classify(transcript: 'concussion', severityHint: 1) + .firstAidQuery, + 'head injury concussion protocol', + ); + }); + + test('default fallback', () { + expect( + classifier + .classify(transcript: 'nothing specific', severityHint: 1) + .firstAidQuery, + 'general road accident first aid emergency response', + ); + }); + }); + }); +} From 463ec1a8b24a5549e6121f5e5a661f0094de112a Mon Sep 17 00:00:00 2001 From: NITISH-R-G <225521762+NITISH-R-G@users.noreply.github.com> Date: Tue, 12 May 2026 04:13:06 +0000 Subject: [PATCH 2/4] perf: optimize government facility seed insert with batching Refactors the `importBundledSeedIfNeeded` loop to accumulate insert parameters and execute a single `executeBatch` query instead of firing an `await execute` query inside the loop. This resolves an N+1 SQLite execution bottleneck. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- .../government_facility_seed_service.dart | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/services/government_facility_seed_service.dart b/lib/services/government_facility_seed_service.dart index 1fd0748..3ef6e93 100644 --- a/lib/services/government_facility_seed_service.dart +++ b/lib/services/government_facility_seed_service.dart @@ -22,32 +22,37 @@ class GovernmentFacilitySeedService { final list = decoded['facilities'] as List? ?? []; var n = 0; + final batchParameters = >[]; for (final item in list) { if (item is! Map) continue; final row = Map.from(item); final id = row['id']?.toString() ?? ''; if (id.isEmpty) continue; - await db.execute( + batchParameters.add([ + id, + row['name'] ?? 'Facility', + row['type'] ?? 'hospital', + (row['latitude'] as num).toDouble(), + (row['longitude'] as num).toDouble(), + row['contact_number'], + row['capabilities'], + row['data_source'] ?? 'gov', + row['state_code'], + row['district'], + ]); + n++; + } + + if (batchParameters.isNotEmpty) { + await db.executeBatch( ''' INSERT OR REPLACE INTO emergency_facilities (id, name, type, latitude, longitude, contact_number, capabilities, data_source, state_code, district) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', - [ - id, - row['name'] ?? 'Facility', - row['type'] ?? 'hospital', - (row['latitude'] as num).toDouble(), - (row['longitude'] as num).toDouble(), - row['contact_number'], - row['capabilities'], - row['data_source'] ?? 'gov', - row['state_code'], - row['district'], - ], + batchParameters, ); - n++; } await prefs.setInt(_prefsKeyImportedVersion, v); From db206498dc7e796d8b69f8adfe31a70fa9b2955c Mon Sep 17 00:00:00 2001 From: NITISH-R-G <225521762+NITISH-R-G@users.noreply.github.com> Date: Tue, 12 May 2026 04:15:47 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20CrashCon?= =?UTF-8?q?fidenceEngine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive unit tests for `CrashConfidenceEngine.score()`. Tests verify tier mapping (LOW, MEDIUM, HIGH) against sensor signal thresholds, ensure negative inputs clamp safely, and validate maximum threshold handling. Also confirms generated factual incident labels meet design constraints. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- pubspec.lock | 56 +++++++++- .../crash_confidence_engine_test.dart | 103 ++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 test/services/crash_confidence_engine_test.dart diff --git a/pubspec.lock b/pubspec.lock index 57ef972..ae0b56f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + url: "https://pub.dev" + source: hosted + version: "0.1.25" bluez: dependency: transitive description: @@ -1029,6 +1037,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.11.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + url: "https://pub.dev" + source: hosted + version: "0.9.46" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" jwt_decode: dependency: transitive description: @@ -1121,10 +1153,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1722,6 +1754,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.4" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -1734,10 +1774,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timezone: dependency: transitive description: @@ -1746,6 +1786,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" + torch_light: + dependency: "direct main" + description: + name: torch_light + sha256: a1397443a375c6991151547cb77361085df6cf8aa59999292e683db7385a0d15 + url: "https://pub.dev" + source: hosted + version: "1.1.0" typed_data: dependency: transitive description: diff --git a/test/services/crash_confidence_engine_test.dart b/test/services/crash_confidence_engine_test.dart new file mode 100644 index 0000000..0c60130 --- /dev/null +++ b/test/services/crash_confidence_engine_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:roadsos/services/crash_confidence_engine.dart'; + +void main() { + group('CrashConfidenceEngine.score', () { + test('returns LOW tier for minor signals', () { + final signals = CrashSignals( + accelPeakMs2: 12.0, // 10% of 120 -> 0.1 * 0.30 = 0.03 + gyroPeakRadPerSec: 0.8, // 10% of 8 -> 0.1 * 0.22 = 0.022 + speedBeforeKmh: 12.0, // 10% of 120 -> 0.1 * 0.20 = 0.02 + speedDropKmh: 12.0, // 10% of 120 -> 0.1 * 0.15 = 0.015 + bluetoothVehicleDisconnect: false, + postImpactDeviceStill: false, + ); + + final result = CrashConfidenceEngine.score(signals); + + expect(result.tier, equals(CrashConfidenceTier.low)); + expect(result.incidentLabel, equals('Possible incident detected')); + expect(result.score, closeTo(0.087, 0.001)); + + expect(result.breakdown['accel'], closeTo(0.03, 0.001)); + expect(result.breakdown['gyro'], closeTo(0.022, 0.001)); + expect(result.breakdown['speed'], closeTo(0.02, 0.001)); + expect(result.breakdown['drop'], closeTo(0.015, 0.001)); + expect(result.breakdown['bt'], equals(0.0)); + expect(result.breakdown['still'], equals(0.0)); + }); + + test('returns MEDIUM tier for moderate signals', () { + final signals = CrashSignals( + accelPeakMs2: 60.0, // 50% of 120 -> 0.5 * 0.30 = 0.15 + gyroPeakRadPerSec: 4.0, // 50% of 8 -> 0.5 * 0.22 = 0.11 + speedBeforeKmh: 60.0, // 50% of 120 -> 0.5 * 0.20 = 0.10 + speedDropKmh: 60.0, // 50% of 120 -> 0.5 * 0.15 = 0.075 + bluetoothVehicleDisconnect: false, + postImpactDeviceStill: false, + ); + + final result = CrashConfidenceEngine.score(signals); + + expect(result.tier, equals(CrashConfidenceTier.medium)); + expect(result.incidentLabel, equals('Possible incident detected')); + expect(result.score, closeTo(0.435, 0.001)); + }); + + test('returns HIGH tier for severe signals', () { + final signals = CrashSignals( + accelPeakMs2: 120.0, + gyroPeakRadPerSec: 8.0, + speedBeforeKmh: 120.0, + speedDropKmh: 120.0, + bluetoothVehicleDisconnect: true, + postImpactDeviceStill: true, + ); + + final result = CrashConfidenceEngine.score(signals); + + expect(result.tier, equals(CrashConfidenceTier.high)); + expect(result.incidentLabel, equals('Detected incident — possible emergency')); + expect(result.score, equals(1.0)); + }); + + test('clamps negative values to 0.0', () { + final signals = CrashSignals( + accelPeakMs2: -10.0, + gyroPeakRadPerSec: -1.0, + speedBeforeKmh: -20.0, + speedDropKmh: -5.0, + bluetoothVehicleDisconnect: false, + postImpactDeviceStill: false, + ); + + final result = CrashConfidenceEngine.score(signals); + + expect(result.score, equals(0.0)); + expect(result.breakdown['accel'], equals(0.0)); + expect(result.breakdown['gyro'], equals(0.0)); + expect(result.breakdown['speed'], equals(0.0)); + expect(result.breakdown['drop'], equals(0.0)); + }); + + test('clamps values exceeding maxima to 1.0 equivalent', () { + final signals = CrashSignals( + accelPeakMs2: 200.0, // max 120.0 + gyroPeakRadPerSec: 20.0, // max 8.0 + speedBeforeKmh: 200.0, // max 120.0 + speedDropKmh: 200.0, // max 120.0 + bluetoothVehicleDisconnect: true, + postImpactDeviceStill: true, + ); + + final result = CrashConfidenceEngine.score(signals); + + expect(result.score, equals(1.0)); + expect(result.tier, equals(CrashConfidenceTier.high)); + expect(result.breakdown['accel'], closeTo(0.30, 0.001)); + expect(result.breakdown['gyro'], closeTo(0.22, 0.001)); + expect(result.breakdown['speed'], closeTo(0.20, 0.001)); + expect(result.breakdown['drop'], closeTo(0.15, 0.001)); + }); + }); +} From 9856085a35f3b75d6fddd9ca3ea90421a807c9b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 17 May 2026 19:46:15 +0000 Subject: [PATCH 4/4] chore: consolidate valuable open PRs (Gemma mirrors, CORS, AGENTS.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(gemma): Unsloth mirror + fallbacks (from #107) — Google HF returns 401 - fix(security): omit CORS Allow-Origin when unset (from #85) — no 'null' origin - docs: add AGENTS.md for Cursor Cloud dev setup (from #101) Co-authored-by: Nitish R.G. --- AGENTS.md | 61 +++++++++++++ lib/services/gemma_model_manager.dart | 111 +++++++++++++++++------ supabase/functions/family-track/index.ts | 2 +- 3 files changed, 143 insertions(+), 31 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e62bd9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +### Project overview + +RoadSOS is a Flutter mobile app (Dart) with Supabase Edge Functions (TypeScript/Deno) as the backend. The primary development loop is Flutter-based: `flutter pub get`, `dart analyze`, `flutter test`, `flutter build web`. + +### Flutter SDK + +The CI pins **Flutter 3.41.9** (Dart 3.11.5). The `pubspec.yaml` SDK constraint is `^3.11.0`. Flutter must be installed at `/opt/flutter` with `/opt/flutter/bin` on `PATH` (the update script handles this). + +### Key commands + +| Action | Command | +|---|---| +| Install deps | `flutter pub get` | +| Lint | `dart analyze` | +| Test | `flutter test` | +| Build web | `flutter build web --dart-define=SUPABASE_URL="$SUPABASE_URL" --dart-define=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY"` | +| Serve demo | `python3 -m http.server 8080` (from repo root, then visit `/demo/index.html`) | +| Serve Flutter web | `cd build/web && python3 -m http.server 8081` | + +### Environment file + +Copy `assets/env.template` to `assets/.env` before running the Flutter app. The app needs `SUPABASE_URL` and `SUPABASE_ANON_KEY` to initialize — without them the Flutter web app shows a blank page. The standalone demo at `demo/index.html` works without any env vars. + +### Testing without external services + +- `dart analyze` and `flutter test` work without any secrets or external services. +- The standalone HTML demo (`demo/index.html`) supports an **Offline Fallback** triage mode that runs keyword-based classification without a Google AI API key. +- The full Flutter web app requires Supabase credentials to initialize (see below). + +### Building and serving the Flutter web app + +The web build is a **release build** by default, so `.env` files are not loaded. Pass secrets via `--dart-define`: + +```bash +flutter build web \ + --dart-define=SUPABASE_URL="$SUPABASE_URL" \ + --dart-define=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" +``` + +Serve from the build output directory directly (not the repo root), so relative asset paths resolve: + +```bash +cd build/web && python3 -m http.server 8081 +``` + +Serving from the repo root (e.g. `http://localhost:8080/build/web/`) will cause 404s for `flutter_bootstrap.js` and `manifest.json`. + +### Gotchas + +- The `flutter_blue_plus_winrt` warnings during `pub get` / build are harmless (Windows-only plugin, irrelevant on Linux). +- Localization warnings about untranslated messages in `bn`, `hi`, `mr`, `ta`, `te` are expected and non-blocking. +- The Wasm dry-run warnings during `flutter build web` are informational only; the JS build succeeds. +- `assets/.env` is not gitignored at the `assets/` path (only `/.env` at root is ignored); do not commit it with secrets. + +### Supabase Edge Functions + +Located in `supabase/functions/`. Deploying requires the Supabase CLI and project credentials. Not required for running tests or the standalone demo. diff --git a/lib/services/gemma_model_manager.dart b/lib/services/gemma_model_manager.dart index 209f75b..500d00c 100644 --- a/lib/services/gemma_model_manager.dart +++ b/lib/services/gemma_model_manager.dart @@ -13,17 +13,21 @@ import '../logging/app_log.dart'; /// • Cancel in-progress download /// • Delete to reclaim space /// -/// Model: gemma-4-e4b-it-Q4_K_M.gguf (~2.4 GB) -/// Source: https://huggingface.co/google/gemma-4-E4B-it-GGUF +/// Model: gemma-4-E4B-it-Q4_K_M.gguf (~2.4 GB) +/// Source: https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF /// -/// **NO TOKEN REQUIRED.** Gemma 4 (released Apr 2026) is **Apache 2.0** + -/// **ungated** on Hugging Face — the earlier code's HF-token gate was -/// inherited from the Gemma 2 days when the license required acceptance -/// and is now pure friction that drives users to skip the download. -/// Downloads now run anonymous-HTTPS with optional token override for -/// users behind a rate-limited HF mirror. +/// **NO TOKEN REQUIRED.** Gemma 4 weights are Apache 2.0, but Google's +/// **official** repo `google/gemma-4-E4B-it-GGUF` still gates the resolve +/// endpoint behind a click-through terms screen + HF login (returns HTTP +/// 401 to anonymous fetches as of May 2026). The Unsloth, ggml-org, and +/// bartowski mirrors redistribute the **identical model bytes** under the +/// same Apache 2.0 license with no gating — verified 302→CDN on anonymous +/// `curl` from a clean IP. We point at Unsloth (most-downloaded GGUF +/// publisher, maintained by the same team behind unsloth.ai's training +/// stack) so the auto-installer just works without any HF account. class GemmaModelManager { - static const _modelFileName = 'gemma-4-e4b-it-Q4_K_M.gguf'; + /// Filename — matches Unsloth's casing for the Q4_K_M quant. + static const _modelFileName = 'gemma-4-E4B-it-Q4_K_M.gguf'; /// Minimum sane file size: anything under 800 MB is definitely truncated. static const int expectedMinBytes = 800 * 1024 * 1024; @@ -31,22 +35,29 @@ class GemmaModelManager { /// Approximate full model size — used for progress UI when Content-Length is missing. static const int approximateFullBytes = 2_400_000_000; - /// HuggingFace download URL. Apache 2.0 + ungated — no Authorization header - /// needed. We keep an optional token override for users who hit an HF - /// rate-limit on a shared IP (very rare for one-time downloads). + /// Primary download URL — Unsloth mirror (no auth required, identical bytes + /// to Google's official repo, Apache 2.0). static const String modelDownloadUrl = - 'https://huggingface.co/google/gemma-4-E4B-it-GGUF/resolve/main/$_modelFileName'; + 'https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/$_modelFileName'; + + /// Fallback mirrors in case the primary is rate-limited or in maintenance. + /// Tried in order; all hold the same Apache 2.0 weights. + static const List modelDownloadFallbackUrls = [ + 'https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/main/$_modelFileName', + 'https://huggingface.co/bartowski/google_gemma-4-E4B-it-GGUF/resolve/main/google_gemma-4-E4B-it-Q4_K_M.gguf', + ]; /// Model card — opens in browser if the user wants to read the license / /// model card before downloading. Not required to proceed. static const String hfModelCardUrl = - 'https://huggingface.co/google/gemma-4-E4B-it-GGUF'; + 'https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF'; /// Optional HF token override page (only needed for very-high-volume IPs). static const String hfTokenUrl = 'https://huggingface.co/settings/tokens'; /// SharedPreferences keys. - static const String prefAutoDownloadOptOut = 'gemma_auto_download_opt_out_v1'; + static const String prefAutoDownloadOptOut = + 'gemma_auto_download_opt_out_v1'; static const String prefAutoDownloadInFlight = 'gemma_auto_download_in_flight_v1'; @@ -110,6 +121,44 @@ class GemmaModelManager { String? hfToken, required void Function(int received, int total) onProgress, CancelToken? cancelToken, + }) async { + final candidateUrls = [ + modelDownloadUrl, + ...modelDownloadFallbackUrls, + ]; + GemmaDownloadException? lastErr; + for (final url in candidateUrls) { + if (cancelToken?.isCancelled ?? false) return; + try { + await _downloadFromUrl( + url: url, + hfToken: hfToken, + onProgress: onProgress, + cancelToken: cancelToken, + ); + return; // success + } on GemmaDownloadException catch (e) { + lastErr = e; + // Only fail-over on auth-ish / rate-limit responses; bubble unknown + // errors so the caller can show a clean message. + if (e.statusCode != 401 && + e.statusCode != 403 && + e.statusCode != 404 && + e.statusCode != 429) { + rethrow; + } + appLog.w('[GemmaModel] $url failed (${e.statusCode}); trying next mirror'); + } + } + throw lastErr ?? + const GemmaDownloadException('All Gemma 4 mirrors are unreachable.'); + } + + static Future _downloadFromUrl({ + required String url, + String? hfToken, + required void Function(int received, int total) onProgress, + CancelToken? cancelToken, }) async { final path = await localModelPath(); final tmpPath = '$path.download'; @@ -120,7 +169,7 @@ class GemmaModelManager { if (tmpFile.existsSync()) { alreadyHave = tmpFile.lengthSync(); appLog.i( - '[GemmaModel] Resuming download from ${(alreadyHave / 1e6).round()} MB', + '[GemmaModel] Resuming download from ${(alreadyHave / 1e6).round()} MB ($url)', ); } @@ -134,7 +183,7 @@ class GemmaModelManager { final client = http.Client(); try { - final request = http.Request('GET', Uri.parse(modelDownloadUrl)); + final request = http.Request('GET', Uri.parse(url)); request.headers.addAll(headers); final response = await client.send(request); @@ -143,21 +192,27 @@ class GemmaModelManager { final body = await response.stream.bytesToString(); if (response.statusCode == 401 || response.statusCode == 403) { throw GemmaDownloadException( - 'Hugging Face refused the request (HTTP ${response.statusCode}). ' - 'This is unusual for Gemma 4 (Apache 2.0 + ungated). Most likely ' - 'cause: a shared IP / VPN hit anonymous rate-limits. ' - 'Workaround: create a free read token at $hfTokenUrl and paste it ' - 'in Settings → Advanced.', + 'Hugging Face mirror $url refused the request (HTTP ' + '${response.statusCode}). Trying the next mirror — if all fail, ' + 'paste an HF read token in Settings → Advanced as a workaround.', statusCode: response.statusCode, ); } if (response.statusCode == 429) { throw GemmaDownloadException( - 'Hugging Face rate-limit reached (HTTP 429). Try again in a few ' - 'minutes, or add an HF read token in Settings → Advanced.', + 'Hugging Face rate-limited mirror $url (HTTP 429). Will retry on ' + 'the next Wi-Fi event, or paste an HF read token in Settings → ' + 'Advanced to override.', statusCode: response.statusCode, ); } + if (response.statusCode == 404) { + throw GemmaDownloadException( + 'Mirror $url is missing the expected GGUF file (HTTP 404). Trying ' + 'the next mirror.', + statusCode: 404, + ); + } throw GemmaDownloadException( 'Server returned HTTP ${response.statusCode}: ${body.substring(0, body.length.clamp(0, 200))}', statusCode: response.statusCode, @@ -178,9 +233,7 @@ class GemmaModelManager { try { await for (final chunk in response.stream) { if (cancelToken?.isCancelled ?? false) { - appLog.i( - '[GemmaModel] Download cancelled — partial file kept for resume', - ); + appLog.i('[GemmaModel] Download cancelled — partial file kept for resume'); await sink.flush(); await sink.close(); return; @@ -207,9 +260,7 @@ class GemmaModelManager { // Atomic rename: .download → final path. await tmpFile.rename(path); - appLog.i( - '[GemmaModel] ✓ Download complete — ${(finalSize / 1e6).round()} MB at $path', - ); + appLog.i('[GemmaModel] ✓ Download complete — ${(finalSize / 1e6).round()} MB at $path'); } finally { client.close(); } diff --git a/supabase/functions/family-track/index.ts b/supabase/functions/family-track/index.ts index cb7f492..f6e6cf6 100644 --- a/supabase/functions/family-track/index.ts +++ b/supabase/functions/family-track/index.ts @@ -14,7 +14,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; const allowOrigin = Deno.env.get("FAMILY_TRACK_ALLOWED_ORIGIN") ?? ""; const cors = { // Default is same-origin only; set FAMILY_TRACK_ALLOWED_ORIGIN="*" explicitly for demos. - "Access-Control-Allow-Origin": allowOrigin.trim() ? allowOrigin.trim() : "null", + ...(allowOrigin.trim() ? { "Access-Control-Allow-Origin": allowOrigin.trim() } : {}), "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, accept", "Access-Control-Allow-Methods": "GET, OPTIONS",