| """ |
| Supabase Database Verifier |
| Replaces SQLite with cloud-hosted Supabase PostgreSQL database |
| """ |
| import os |
| from typing import Dict, Any, List, Optional, Tuple |
| from rapidfuzz import fuzz |
| import re |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| class SupabaseCertificateVerifier: |
| """Certificate verification engine using Supabase cloud database.""" |
| |
| def __init__(self, supabase_url: Optional[str] = None, supabase_key: Optional[str] = None): |
| """ |
| Initialize the verifier with Supabase connection. |
| |
| Args: |
| supabase_url: Supabase project URL (or from SUPABASE_URL env var) |
| supabase_key: Supabase anon/public key (or from SUPABASE_KEY env var) |
| """ |
| try: |
| from supabase import create_client, Client |
| except ImportError: |
| raise ImportError("Please install supabase: pip install supabase") |
| |
| self.supabase_url = supabase_url or os.getenv('SUPABASE_URL') |
| self.supabase_key = supabase_key or os.getenv('SUPABASE_KEY') |
| |
| if not self.supabase_url or not self.supabase_key: |
| raise ValueError("Supabase credentials not provided. Set SUPABASE_URL and SUPABASE_KEY environment variables.") |
| |
| self.supabase: Client = create_client(self.supabase_url, self.supabase_key) |
| |
| |
| self.reg_patterns = [ |
| r'\d[A-Z]{2}\d{2}[A-Z]{2}\d{3}', |
| r'USN:?\s*\d[A-Z]{2}\d{2}[A-Z]{2}\d{3}', |
| r'[A-Z]{2,4}[-_]?\d{4}[-_]?\d{3}', |
| r'[A-Z]{3,5}[-_]?\d{2,6}', |
| r'REG[-_]?\d{4}[-_]?\d{3}', |
| r'CERT[-_]?\d{4}', |
| r'EDU[-_]?\d{4}', |
| r'COL[-_]?\d{4}', |
| r'STU[-_]?\d{4}', |
| r'[A-Z]+[-_]?\d+[-_]?[A-Z]*' |
| ] |
| |
| |
| self.field_weights = { |
| 'name': 0.35, |
| 'father_name': 0.25, |
| 'institution': 0.2, |
| 'degree': 0.15, |
| 'year': 0.05 |
| } |
| |
| |
| self.authentic_threshold = 0.75 |
| self.suspect_threshold = 0.4 |
| |
| def verify_certificate(self, ocr_result: Dict[str, Any], |
| image_filename: Optional[str] = None) -> Dict[str, Any]: |
| """ |
| Verify a certificate using OCR results and Supabase database lookup. |
| |
| Args: |
| ocr_result: OCR result dictionary from ocr_client |
| image_filename: Original image filename (optional) |
| |
| Returns: |
| Structured verification result |
| """ |
| if not ocr_result.get('success', False): |
| return { |
| 'registration_no': None, |
| 'db_record': None, |
| 'ocr_extracted': {'raw_text': ocr_result.get('error', 'OCR failed')}, |
| 'field_scores': {}, |
| 'final_score': 0.0, |
| 'decision': 'NOT_FOUND', |
| 'reasons': ['OCR processing failed'], |
| 'confidence': 0.0 |
| } |
| |
| extracted_text = ocr_result.get('extracted_text', '') |
| |
| |
| reg_numbers = self._extract_registration_numbers(extracted_text) |
| |
| if not reg_numbers: |
| return { |
| 'registration_no': None, |
| 'db_record': None, |
| 'ocr_extracted': { |
| 'raw_text': extracted_text, |
| 'name': None, |
| 'institution': None, |
| 'degree': None, |
| 'year': None |
| }, |
| 'field_scores': {}, |
| 'final_score': 0.0, |
| 'decision': 'NOT_FOUND', |
| 'reasons': ['No registration number found in OCR text'], |
| 'confidence': 0.0 |
| } |
| |
| |
| best_result = None |
| best_score = 0.0 |
| |
| for reg_no in reg_numbers: |
| |
| db_record = self._lookup_registration(reg_no) |
| |
| if db_record: |
| |
| ocr_extracted = self._extract_fields_from_ocr(extracted_text, db_record) |
| |
| |
| field_scores = self._compare_fields(db_record, ocr_extracted) |
| final_score = self._calculate_final_score(field_scores) |
| |
| |
| decision, reasons = self._make_decision(final_score, field_scores, reg_no) |
| |
| result = { |
| 'registration_no': reg_no, |
| 'db_record': db_record, |
| 'ocr_extracted': ocr_extracted, |
| 'field_scores': field_scores, |
| 'final_score': final_score, |
| 'decision': decision, |
| 'reasons': reasons, |
| 'confidence': ocr_result.get('confidence', 0.5), |
| 'bounding_boxes': ocr_result.get('bounding_boxes', []) |
| } |
| |
| if final_score > best_score: |
| best_result = result |
| best_score = final_score |
| |
| return best_result if best_result else { |
| 'registration_no': reg_numbers[0] if reg_numbers else None, |
| 'db_record': None, |
| 'ocr_extracted': { |
| 'raw_text': extracted_text, |
| 'name': None, |
| 'institution': None, |
| 'degree': None, |
| 'year': None |
| }, |
| 'field_scores': {}, |
| 'final_score': 0.0, |
| 'decision': 'NOT_FOUND', |
| 'reasons': [f'Registration number {reg_numbers[0]} not found in database'], |
| 'confidence': ocr_result.get('confidence', 0.5) |
| } |
| |
| def _extract_registration_numbers(self, text: str) -> List[str]: |
| """Extract potential registration numbers from OCR text.""" |
| reg_numbers = [] |
| |
| for pattern in self.reg_patterns: |
| matches = re.findall(pattern, text, re.IGNORECASE) |
| for match in matches: |
| |
| clean_match = re.sub(r'USN:?\s*', '', match, flags=re.IGNORECASE) |
| clean_match = re.sub(r'[-_\s]+', '', clean_match.upper()) |
| if clean_match not in reg_numbers and len(clean_match) > 3: |
| reg_numbers.append(clean_match) |
| |
| return reg_numbers |
| |
| def _lookup_registration(self, reg_no: str) -> Optional[Dict[str, Any]]: |
| """Look up registration number in Supabase database.""" |
| try: |
| print(f"[DEBUG] Looking up registration: {reg_no}") |
| |
| |
| response = self.supabase.table('certificates') \ |
| .select('*') \ |
| .ilike('reg_no', reg_no) \ |
| .execute() |
| |
| print(f"[DEBUG] Lookup response: {len(response.data) if response.data else 0} records") |
| |
| if response.data and len(response.data) > 0: |
| print(f"[DEBUG] Found record: {response.data[0].get('reg_no', 'N/A')}") |
| return response.data[0] |
| |
| |
| print(f"[DEBUG] No exact match, trying fuzzy matching...") |
| all_records = self.supabase.table('certificates') \ |
| .select('reg_no, id') \ |
| .execute() |
| |
| print(f"[DEBUG] Total records for fuzzy match: {len(all_records.data) if all_records.data else 0}") |
| |
| best_match = None |
| best_score = 0 |
| |
| for record in all_records.data: |
| if record.get('reg_no'): |
| score = fuzz.ratio(reg_no.upper(), record['reg_no'].upper()) / 100.0 |
| if score > best_score and score > 0.8: |
| best_score = score |
| best_match = record['id'] |
| print(f"[DEBUG] Fuzzy match candidate: {record['reg_no']} (score: {score})") |
| |
| if best_match: |
| print(f"[DEBUG] Best fuzzy match ID: {best_match} (score: {best_score})") |
| response = self.supabase.table('certificates') \ |
| .select('*') \ |
| .eq('id', best_match) \ |
| .execute() |
| |
| if response.data: |
| return response.data[0] |
| |
| print(f"[DEBUG] No match found for: {reg_no}") |
| |
| except Exception as e: |
| print(f"[ERROR] Supabase lookup error: {e}") |
| |
| return None |
|
|
| def _extract_fields_from_ocr(self, text: str, db_record: Dict[str, Any]) -> Dict[str, Any]: |
| """Extract relevant fields from OCR text using the database record as a guide.""" |
| lines = [line.strip() for line in text.split('\n') if line.strip()] |
| words = text.split() |
| |
| extracted = { |
| 'raw_text': text, |
| 'name': None, |
| 'institution': None, |
| 'degree': None, |
| 'year': None, |
| 'father_name': None |
| } |
| |
| |
| if not db_record: |
| return extracted |
| |
| |
| if db_record.get('name'): |
| db_name = db_record['name'].upper() |
| best_name_match = None |
| best_name_score = 0 |
| |
| for i in range(len(words)): |
| for j in range(i + 1, min(i + 4, len(words) + 1)): |
| candidate = ' '.join(words[i:j]).upper() |
| if not any(skip in candidate for skip in ['CERTIFICATE', 'COMPLETION', 'CERTIFY']): |
| score = fuzz.ratio(candidate, db_name) / 100.0 |
| if score > best_name_score and score > 0.6: |
| best_name_score = score |
| best_name_match = candidate |
| |
| extracted['name'] = best_name_match |
| |
| |
| if db_record.get('father_name'): |
| db_father = db_record['father_name'].upper() |
| best_father_score = 0 |
| best_father_match = None |
| |
| for i in range(len(words)): |
| for j in range(i + 1, min(i + 4, len(words) + 1)): |
| candidate = ' '.join(words[i:j]).upper() |
| if not any(skip in candidate for skip in ['CERTIFICATE', 'UNIVERSITY']): |
| score = fuzz.ratio(candidate, db_father) / 100.0 |
| if score > best_father_score and score > 0.6: |
| best_father_score = score |
| best_father_match = candidate |
| |
| extracted['father_name'] = best_father_match |
| |
| |
| if db_record.get('institution'): |
| db_institution = db_record['institution'].upper() |
| best_inst_score = 0 |
| best_inst_match = None |
| |
| for line in lines: |
| score = fuzz.partial_ratio(line.upper(), db_institution) / 100.0 |
| if score > best_inst_score and score > 0.7: |
| best_inst_score = score |
| best_inst_match = line |
| |
| extracted['institution'] = best_inst_match |
| |
| |
| if db_record.get('degree'): |
| db_degree = db_record['degree'].upper() |
| |
| for line in lines: |
| score = fuzz.partial_ratio(line.upper(), db_degree) / 100.0 |
| if score > 0.7: |
| extracted['degree'] = line |
| break |
| |
| |
| if db_record.get('year'): |
| db_year = db_record['year'] |
| if str(db_year) in text: |
| extracted['year'] = db_year |
| else: |
| year_matches = re.findall(r'\b(20\d{2}|19\d{2})\b', text) |
| if year_matches: |
| years = [int(y) for y in year_matches if 1990 <= int(y) <= 2030] |
| if years: |
| extracted['year'] = min(years, key=lambda x: abs(x - db_year)) |
| |
| return extracted |
| |
| def _compare_fields(self, db_record: Dict[str, Any], |
| ocr_extracted: Dict[str, Any]) -> Dict[str, float]: |
| """Compare database record fields with OCR extracted fields.""" |
| scores = {} |
| |
| |
| if db_record.get('name') and ocr_extracted.get('name'): |
| name_db = db_record['name'].upper().strip() |
| name_ocr = ocr_extracted['name'].upper().strip() |
| scores['name'] = max( |
| fuzz.ratio(name_db, name_ocr) / 100.0, |
| fuzz.token_sort_ratio(name_db, name_ocr) / 100.0 |
| ) |
| else: |
| scores['name'] = 0.0 |
| |
| |
| if db_record.get('father_name') and ocr_extracted.get('father_name'): |
| father_db = db_record['father_name'].upper().strip() |
| father_ocr = ocr_extracted['father_name'].upper().strip() |
| scores['father_name'] = max( |
| fuzz.ratio(father_db, father_ocr) / 100.0, |
| fuzz.token_sort_ratio(father_db, father_ocr) / 100.0 |
| ) |
| else: |
| scores['father_name'] = 0.0 |
| |
| |
| if db_record.get('institution') and ocr_extracted.get('institution'): |
| inst_db = db_record['institution'].upper().strip() |
| inst_ocr = ocr_extracted['institution'].upper().strip() |
| scores['institution'] = fuzz.partial_ratio(inst_db, inst_ocr) / 100.0 |
| else: |
| scores['institution'] = 0.0 |
| |
| |
| if db_record.get('degree') and ocr_extracted.get('degree'): |
| degree_db = db_record['degree'].upper().strip() |
| degree_ocr = ocr_extracted['degree'].upper().strip() |
| scores['degree'] = fuzz.partial_ratio(degree_db, degree_ocr) / 100.0 |
| else: |
| scores['degree'] = 0.0 |
| |
| |
| if db_record.get('year') and ocr_extracted.get('year'): |
| year_diff = abs(db_record['year'] - ocr_extracted['year']) |
| if year_diff == 0: |
| scores['year'] = 1.0 |
| elif year_diff == 1: |
| scores['year'] = 0.9 |
| elif year_diff == 2: |
| scores['year'] = 0.7 |
| else: |
| scores['year'] = 0.0 |
| else: |
| scores['year'] = 0.0 |
| |
| return scores |
| |
| def _calculate_final_score(self, field_scores: Dict[str, float]) -> float: |
| """Calculate weighted final score.""" |
| total_weight = sum(self.field_weights.values()) |
| weighted_sum = sum( |
| score * self.field_weights.get(field, 0) |
| for field, score in field_scores.items() |
| ) |
| return weighted_sum / total_weight if total_weight > 0 else 0.0 |
| |
| def _make_decision(self, final_score: float, field_scores: Dict[str, float], |
| reg_no: str) -> Tuple[str, List[str]]: |
| """Make final verification decision and provide reasons.""" |
| reasons = [] |
| |
| for field, score in field_scores.items(): |
| if score >= 0.9: |
| reasons.append(f"{field} match excellent ({score:.2f})") |
| elif score >= 0.7: |
| reasons.append(f"{field} match good ({score:.2f})") |
| elif score >= 0.5: |
| reasons.append(f"{field} match moderate ({score:.2f})") |
| elif score > 0: |
| reasons.append(f"{field} match poor ({score:.2f})") |
| else: |
| reasons.append(f"{field} not found or no match") |
| |
| reasons.append(f"Registration number {reg_no} found in database") |
| |
| if final_score >= self.authentic_threshold: |
| decision = "AUTHENTIC" |
| reasons.append(f"High confidence score ({final_score:.2f})") |
| elif final_score >= self.suspect_threshold: |
| decision = "SUSPECT" |
| reasons.append(f"Moderate confidence score ({final_score:.2f}) - needs manual review") |
| else: |
| decision = "SUSPECT" |
| reasons.append(f"Low confidence score ({final_score:.2f}) - likely fraudulent") |
| |
| return decision, reasons |
|
|
|
|
| def _lookup_subjects(self, reg_no: str) -> List[Dict[str, Any]]: |
| """ |
| Look up subject grades for a registration number from Supabase. |
| |
| Args: |
| reg_no: Registration number |
| |
| Returns: |
| List of subject records |
| """ |
| try: |
| print(f"[DEBUG] Looking up subjects for reg_no: {reg_no}") |
| |
| |
| response = self.supabase.table('certificate_subjects') \ |
| .select('subject_code, subject_name, credits_registered, credits_earned, grade, grade_points, semester') \ |
| .ilike('reg_no', reg_no) \ |
| .order('subject_code') \ |
| .execute() |
| |
| print(f"[DEBUG] Subjects response: {len(response.data) if response.data else 0} records") |
| |
| if response.data: |
| return response.data |
| |
| return [] |
| |
| except Exception as e: |
| print(f"[ERROR] Subject lookup error: {e}") |
| return [] |
| |
| def _lookup_summary(self, reg_no: str) -> Optional[Dict[str, Any]]: |
| """ |
| Look up certificate summary (credits, SGPA, CGPA) for a registration number. |
| |
| Args: |
| reg_no: Registration number |
| |
| Returns: |
| Summary record with total_credits_earned, sgpa, cgpa |
| """ |
| try: |
| print(f"[DEBUG] Looking up summary for reg_no: {reg_no}") |
| |
| |
| response = self.supabase.table('certificate_summary') \ |
| .select('total_credits_earned, sgpa, cgpa') \ |
| .ilike('reg_no', reg_no) \ |
| .execute() |
| |
| print(f"[DEBUG] Summary response: {response.data}") |
| |
| if response.data and len(response.data) > 0: |
| return response.data[0] |
| |
| return None |
| |
| except Exception as e: |
| print(f"[ERROR] Summary lookup error: {e}") |
| return None |
|
|
|
|
| |
| CertificateVerifier = SupabaseCertificateVerifier |
|
|