import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:sqflite/sqflite.dart'; /// Result of a vector-similarity search against the scam_embeddings table. class VectorMatch { final String category; final double distance; final bool isHighRisk; final bool isMediumRisk; bool get isScam => isMediumRisk || isHighRisk; String get threatLevel => isHighRisk ? 'HIGH' : isMediumRisk ? 'MEDIUM' : 'LOW'; VectorMatch({ required this.category, required this.distance, required this.isHighRisk, required this.isMediumRisk, }); } /// Encapsulates sqlite-vec Layer 2 of the Kavacha pipeline. /// /// Design principle: NEVER throw out. All failures are caught and logged. /// The pipeline must continue with layers 1, 3, 4 if this layer is unavailable. class VectorService { static final VectorService _instance = VectorService._internal(); factory VectorService() => _instance; VectorService._internal(); static bool _isReady = false; static bool get isReady => _isReady; /// The sqlite-vec version string, available after successful [initialize]. static String vecVersion = 'not loaded'; /// Initialize the sqlite-vec extension and create the virtual table. /// /// Must be called once, after the database is opened, before the pipeline runs. /// Safe to call multiple times — subsequent calls are no-ops. static Future initialize(Database db) async { if (_isReady) return; try { // CRITICAL: enable_load_extension MUST come before load_extension. // SQLite on Android disables extension loading by default. await db.execute("PRAGMA enable_load_extension = 1"); await db.execute("SELECT load_extension('libsqlitevec')"); await db.execute("PRAGMA enable_load_extension = 0"); // Verify extension loaded correctly. final version = await db.rawQuery("SELECT vec_version()"); vecVersion = version.first.values.first?.toString() ?? 'unknown'; print('[VectorService] sqlite-vec version: $vecVersion'); // Create virtual vector table for scam embeddings (128-dimension). await db.execute(''' CREATE VIRTUAL TABLE IF NOT EXISTS scam_embeddings USING vec0( embedding FLOAT[384], +category TEXT, +source TEXT, +created_at INTEGER ) '''); _isReady = true; print('[VectorService] Layer 2 ready'); } catch (e) { _isReady = false; vecVersion = 'not loaded'; print('[VectorService] Failed to load: $e'); print('[VectorService] Layer 2 disabled — pipeline continues without it'); // Do NOT rethrow — Kavacha pipeline must continue with layers 1, 3, 4. } } /// Store a confirmed scam as an embedding for future matching. Future addScamEmbedding({ required Database db, required List embedding, required String category, required String source, }) async { if (!_isReady) return; try { await db.execute( 'INSERT INTO scam_embeddings(embedding, category, source, created_at) VALUES (?, ?, ?, ?)', [ '[${embedding.join(',')}]', category, source, DateTime.now().millisecondsSinceEpoch, ], ); } catch (e) { print('[VectorService] addScamEmbedding error: $e'); } } /// Find the closest scam pattern in the embedding store. /// /// Returns [VectorMatch] if a candidate is found, [null] if not or layer is disabled. Future findClosest({ required Database db, required List queryEmbedding, double highRiskThreshold = 0.15, double mediumRiskThreshold = 0.35, }) async { if (!_isReady) return null; try { final results = await db.rawQuery(''' SELECT category, source, distance FROM scam_embeddings WHERE embedding MATCH ? ORDER BY distance LIMIT 1 ''', ['[${queryEmbedding.join(',')}]']); if (results.isEmpty) return null; final distance = (results.first['distance'] as num).toDouble(); final category = results.first['category'] as String; return VectorMatch( category: category, distance: distance, isHighRisk: distance < highRiskThreshold, isMediumRisk: distance < mediumRiskThreshold, ); } catch (e) { print('[VectorService] findClosest error: $e'); return null; } } /// Convenience accessor that uses StorageService's shared DB. Future findClosestScam(List queryEmbedding) async { if (!_isReady) return null; // This path is used by layer2_vector.dart which has its own db reference. // The caller passes the embedding; DB must be provided via findClosest(). // Return null here to signal the caller to use findClosest() directly. return null; } /// Seed the vector store with pre-computed scam embeddings from a JSON asset. /// /// Call once on first install. Skips silently if embeddings already exist. Future seedEmbeddings(Database db, String assetPath) async { if (!_isReady) return; try { final countResult = await db.rawQuery( 'SELECT COUNT(*) as c FROM scam_embeddings', ); final existing = (countResult.first['c'] as int); if (existing > 0) { print('[VectorService] Already seeded ($existing embeddings), skipping'); return; } print('[VectorService] Seeding embeddings from $assetPath'); final String jsonString = await rootBundle.loadString(assetPath); final List data = jsonDecode(jsonString); final batch = db.batch(); for (final item in data) { final List rawEmbedding = item['embedding'] as List; final embeddingStr = '[${rawEmbedding.map((v) => (v as num).toDouble()).join(',')}]'; batch.execute( 'INSERT INTO scam_embeddings(embedding, category, source, created_at) VALUES (?, ?, ?, ?)', [ embeddingStr, item['category'] as String, item['source'] as String, DateTime.now().millisecondsSinceEpoch, ], ); } await batch.commit(noResult: true); print('[VectorService] Seeded ${data.length} embeddings'); } catch (e) { print('[VectorService] Seed error: $e'); } } /// Returns the count of embeddings currently in the store. Future embeddingCount(Database db) async { if (!_isReady) return 0; try { final result = await db.rawQuery( 'SELECT COUNT(*) as c FROM scam_embeddings', ); return (result.first['c'] as int); } catch (_) { return 0; } } }