Spaces:
Sleeping
Sleeping
| """Unified document analysis pipeline β no YOLO, EasyOCR only.""" | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import logging | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| from config import MASKED_OUTPUT, ORIGINAL_UPLOADS | |
| from ml_utils.confidence import build_score_breakdown, decide_status | |
| from ml_utils.edge_detect import edge_inconsistency_score | |
| from ml_utils.extract import extract_fields | |
| from ml_utils.forgery_detector import detect_forgery | |
| from ml_utils.fft_detect import fft_anomaly_score | |
| from ml_utils.mask import mask_pii_on_image, save_masked_image | |
| from ml_utils.ocr import OcrResult, get_full_text, get_average_confidence, ocr_multipass | |
| from ml_utils.text_extractor import extract_with_ocr_results | |
| logger = logging.getLogger("docverify.service") | |
| def _image_to_base64(image_bgr: np.ndarray | None) -> str | None: | |
| """Encode a BGR image to base64 JPEG string for Supabase storage.""" | |
| if image_bgr is None: | |
| return None | |
| try: | |
| _, buffer = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 85]) | |
| return base64.b64encode(buffer).decode("utf-8") | |
| except Exception: | |
| return None | |
| def _file_to_base64(file_path: str) -> str | None: | |
| """Read a file and encode to base64.""" | |
| try: | |
| with open(file_path, "rb") as f: | |
| return base64.b64encode(f.read()).decode("utf-8") | |
| except Exception: | |
| return None | |
| def _save_original(file_path: str, doc_id: str) -> str | None: | |
| """Copy original upload to persistent storage for preview.""" | |
| src = Path(file_path) | |
| if not src.exists(): | |
| return None | |
| ORIGINAL_UPLOADS.mkdir(parents=True, exist_ok=True) | |
| dest = ORIGINAL_UPLOADS / f"{doc_id}{src.suffix}" | |
| try: | |
| import shutil | |
| shutil.copy2(str(src), str(dest)) | |
| return str(dest) | |
| except Exception: | |
| return None | |
| def _validate_for_doc_type(fields: dict, doc_type: str, text: str, text_source: str, ocr_results: list[OcrResult]) -> tuple[float, list[str]]: | |
| """Route to the correct validator and return (validation_score_0_to_20, flags).""" | |
| if doc_type == "aadhaar": | |
| from ml_utils.validators.aadhaar import validate_aadhaar | |
| return validate_aadhaar(fields, ocr_results) | |
| elif doc_type == "pan": | |
| from ml_utils.validators.pan import validate_pan | |
| return validate_pan(fields, ocr_results) | |
| elif doc_type == "caste": | |
| from ml_utils.validators.caste import validate_caste | |
| return validate_caste(fields, text, ocr_results) | |
| elif doc_type == "experience": | |
| from ml_utils.validators.experience import validate_experience | |
| return validate_experience(fields, text, text_source, ocr_results) | |
| elif doc_type == "education": | |
| from ml_utils.validators.education import validate_education | |
| return validate_education(fields, ocr_results) | |
| elif doc_type == "resume": | |
| from ml_utils.validators.resume import validate_resume | |
| return validate_resume(fields, ocr_results) | |
| else: | |
| from ml_utils.validators.general import validate_general | |
| return validate_general(fields, ocr_results) | |
| def _clean_fields_for_display(fields: dict, doc_type: str) -> dict: | |
| """Return only user-facing fields, hiding internal/technical keys.""" | |
| try: | |
| if doc_type == "aadhaar": | |
| from ml_utils.validators.aadhaar import build_extracted_output | |
| return build_extracted_output(fields) | |
| elif doc_type == "pan": | |
| # PAN: hide signature_present and pan_validated (internal) | |
| HIDDEN = {"signature_present", "pan_validated"} | |
| return {k: v for k, v in fields.items() if k not in HIDDEN} | |
| else: | |
| return fields | |
| except Exception: | |
| return fields | |
| def analyze_document(doc_type: str, file_path: str, doc_id: str) -> dict: | |
| """One unified pipeline for ALL document types.""" | |
| try: | |
| return _run_pipeline(doc_type, file_path, doc_id) | |
| except Exception as exc: | |
| logger.exception("Analysis failed for %s (%s)", doc_id, doc_type) | |
| return { | |
| "confidence_score": 0.0, | |
| "score_breakdown": {"ocr_quality": 0, "field_completeness": 0, "validation": 0, "image_quality": 0, "overall": 0}, | |
| "flags": [f"ANALYSIS_ERROR: {type(exc).__name__}"], | |
| "extracted_fields": {}, | |
| "full_text": "", | |
| "status": "Manual Review Required", | |
| "text_source": None, | |
| "masked_image_path": None, | |
| "ocr_confidence": 0.0, | |
| "original_path": None, | |
| "image_base64": None, | |
| "masked_image_base64": None, | |
| } | |
| def _run_pipeline(doc_type: str, file_path: str, doc_id: str) -> dict: | |
| # 0. Save original for preview + encode to base64 | |
| original_path = _save_original(file_path, doc_id) | |
| image_b64 = _file_to_base64(file_path) | |
| # 1. Extract text + OCR results + image | |
| text, text_source, ocr_results, image = extract_with_ocr_results(file_path) | |
| if not text.strip() and not ocr_results and image is None: | |
| return { | |
| "confidence_score": 0.0, | |
| "score_breakdown": {"ocr_quality": 0, "field_completeness": 0, "validation": 0, "image_quality": 0, "overall": 0}, | |
| "flags": ["TEXT_EXTRACT_FAILED"], | |
| "extracted_fields": {}, | |
| "full_text": "", | |
| "status": "Rejected", | |
| "text_source": text_source, | |
| "masked_image_path": None, | |
| "ocr_confidence": 0.0, | |
| "original_path": original_path, | |
| "image_base64": image_b64, | |
| "masked_image_base64": None, | |
| "forgery_score": 0.0, | |
| } | |
| # 2. OCR-based field extraction | |
| fields = extract_fields(ocr_results, doc_type) if ocr_results else {} | |
| if not ocr_results and text: | |
| if doc_type == "caste": | |
| from ml_utils.validators.caste import parse_caste_fields | |
| fields = parse_caste_fields(text) | |
| elif doc_type == "experience": | |
| from ml_utils.validators.experience import parse_experience_fields | |
| fields = parse_experience_fields(text) | |
| full_text = text if text else get_full_text(ocr_results) | |
| avg_conf = get_average_confidence(ocr_results) if ocr_results else 0.8 | |
| # 3. ββ GEMINI VISION ANALYSIS ββββββββββββββββββββββββββββββββββββββββββ | |
| gemini_result = None | |
| gemini_forgery_score: float = 0.0 | |
| gemini_flags: list[str] = [] | |
| if image is not None: | |
| try: | |
| from ml_utils.gemini_analyzer import analyze_with_gemini, merge_fields | |
| # Pass PDF text layer if available β Gemini uses it as extra context | |
| pdf_text_hint = text if text_source == "pdf_text" else None | |
| gemini_result = analyze_with_gemini(image, doc_type, pdf_text=pdf_text_hint) | |
| if gemini_result.used_gemini: | |
| # Merge: Gemini fields override OCR for same keys, OCR fills gaps | |
| fields = merge_fields(gemini_result.fields, fields, doc_type) | |
| gemini_forgery_score = gemini_result.forgery_score | |
| if gemini_result.is_suspicious: | |
| gemini_flags = ["POSSIBLE_DOCUMENT_MANIPULATION"] | |
| logger.info("Gemini forgery: suspicious doc=%s score=%.1f reason=%s", | |
| doc_id, gemini_forgery_score, gemini_result.forgery_reason) | |
| else: | |
| logger.info("Gemini forgery: clean doc=%s score=%.1f", doc_id, gemini_forgery_score) | |
| else: | |
| logger.info("Gemini unavailable (%s) β OCR-only mode", gemini_result.error) | |
| except Exception: | |
| logger.warning("Gemini integration failed for %s β using OCR only", doc_id) | |
| # 4. Rule-based validation | |
| validation_score, validation_flags = _validate_for_doc_type(fields, doc_type, full_text, text_source, ocr_results) | |
| # 5. Image quality checks (FFT + edge β but NOT the aggressive ELA forgery detector) | |
| fft_score: float | None = None | |
| edge_score: float | None = None | |
| fft_flags: list[str] = [] | |
| edge_flags: list[str] = [] | |
| if image is not None: | |
| try: | |
| fft_score, fft_flags = fft_anomaly_score(image) | |
| except Exception: | |
| pass | |
| try: | |
| edge_score, edge_flags = edge_inconsistency_score(image) | |
| except Exception: | |
| pass | |
| # 6. Confidence scoring | |
| breakdown = build_score_breakdown(ocr_results, fields, doc_type, validation_score, fft_score, edge_score) | |
| rule_score = breakdown["overall"] | |
| # ββ Blend Gemini AI confidence with rule-based score ββββββββββββββββββ | |
| # If Gemini ran: 65% Gemini AI confidence + 35% rule-based | |
| # If Gemini failed: 100% rule-based | |
| if gemini_result and gemini_result.used_gemini: | |
| gemini_contrib = gemini_result.ai_confidence * 0.70 | |
| rule_contrib = rule_score * 0.30 | |
| final_score = round(gemini_contrib + rule_contrib, 1) | |
| else: | |
| final_score = rule_score | |
| # Apply forgery penalty (Gemini-based, more accurate) | |
| if gemini_forgery_score > 35: | |
| penalty = min((gemini_forgery_score - 35) * 0.5, 35) # max -35pts | |
| final_score = max(0.0, round(final_score - penalty, 1)) | |
| breakdown["overall"] = final_score | |
| # 7. PII masking | |
| masked_path = None | |
| masked_b64 = None | |
| if image is not None and doc_type in ("aadhaar", "pan") and ocr_results: | |
| try: | |
| masked = mask_pii_on_image(image, ocr_results, doc_type) | |
| masked_path = save_masked_image(masked, MASKED_OUTPUT / f"{doc_id}_masked.jpg") | |
| masked_b64 = _image_to_base64(masked) | |
| except Exception: | |
| logger.warning("PII masking failed for %s", doc_id) | |
| # 8. Combine flags | |
| all_flags = list(dict.fromkeys(validation_flags + gemini_flags + fft_flags + edge_flags)) | |
| if avg_conf < 0.4 and "LOW_OCR_CONFIDENCE" not in all_flags: | |
| all_flags.append("LOW_OCR_CONFIDENCE") | |
| # 9. Decision | |
| status = decide_status(final_score, all_flags) | |
| # 10. Clean for display | |
| display_fields = _clean_fields_for_display(fields, doc_type) | |
| return { | |
| "confidence_score": final_score, | |
| "score_breakdown": breakdown, | |
| "flags": all_flags, | |
| "extracted_fields": display_fields, | |
| "full_text": full_text, | |
| "status": status, | |
| "text_source": text_source, | |
| "masked_image_path": masked_path, | |
| "ocr_confidence": round(avg_conf, 3), | |
| "original_path": original_path, | |
| "image_base64": image_b64, | |
| "masked_image_base64": masked_b64, | |
| "forgery_score": round(gemini_forgery_score, 1), | |
| "forgery_reason": gemini_result.forgery_reason if gemini_result and gemini_result.used_gemini else "", | |
| "ai_confidence": gemini_result.ai_confidence if gemini_result and gemini_result.used_gemini else None, | |
| "ai_powered": bool(gemini_result and gemini_result.used_gemini), | |
| "gemini_model": gemini_result.gemini_model if gemini_result else None, | |
| "gemini_raw_json": gemini_result.raw_json if gemini_result else None, | |
| "gemini_key_index": gemini_result.key_index if gemini_result else None, | |
| } | |
| def doc_to_db_json(result: dict) -> dict: | |
| """Convert analysis result to DB-storable format.""" | |
| return { | |
| "confidence_score": result["confidence_score"], | |
| "status": result["status"], | |
| "flags_json": json.dumps(result["flags"]), | |
| "fields_json": json.dumps(result["extracted_fields"], default=str), | |
| "text_source": result.get("text_source"), | |
| "masked_image_path": result.get("masked_image_path"), | |
| "full_text": result.get("full_text", ""), | |
| "score_breakdown_json": json.dumps(result.get("score_breakdown", {})), | |
| "ocr_confidence": result.get("ocr_confidence"), | |
| "image_base64": result.get("image_base64"), | |
| "masked_image_base64": result.get("masked_image_base64"), | |
| "gemini_model": result.get("gemini_model"), | |
| "gemini_raw_json": result.get("gemini_raw_json"), | |
| "forgery_score": result.get("forgery_score"), | |
| "forgery_reason": result.get("forgery_reason"), | |
| "ai_confidence": result.get("ai_confidence"), | |
| "ai_powered": result.get("ai_powered", False), | |
| "gemini_key_index": result.get("gemini_key_index"), | |
| } | |