Spaces:
Sleeping
Sleeping
File size: 8,761 Bytes
0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 0a78fb1 0e39d80 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | """Gemini Vision AI analyzer β token-efficient JSON-only document verification.
Key design decisions:
- gemini-3-flash: 1M context, 1000 RPD free, agentic vision at high media_resolution
- Single API call returns fields + forgery score + ai_confidence
- PDFs rendered to PNG image before analysis (never raw PDF bytes)
- max_output_tokens=400: covers full JSON output with room to spare
- response_mime_type='application/json' forces valid JSON output
- All keys rotate via call_with_failover (5-key pool = ~5000 req/day free)
"""
from __future__ import annotations
import io
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Optional
import cv2
import numpy as np
from ml_utils.gemini_key_pool import call_with_failover
from ml_utils.gemini_prompts import build_prompt
logger = logging.getLogger("docverify.gemini")
@dataclass
class GeminiResult:
fields: dict = field(default_factory=dict)
forgery_score: float = 0.0
forgery_reason: str = ""
ai_confidence: float = 0.0
ai_confidence_reason: str = ""
is_suspicious: bool = False
used_gemini: bool = False
error: Optional[str] = None
raw_json: Optional[str] = None
gemini_model: Optional[str] = None
key_index: Optional[int] = None
def _prepare_image(image_bgr: np.ndarray, max_dim: int = 1024) -> np.ndarray:
"""Resize image preserving aspect ratio. gemini-3-flash handles up to 3072px
but 1024px is optimal for token efficiency at high_res mode."""
h, w = image_bgr.shape[:2]
if max(h, w) > max_dim:
scale = max_dim / max(h, w)
image_bgr = cv2.resize(
image_bgr, (int(w * scale), int(h * scale)),
interpolation=cv2.INTER_LANCZOS4
)
return image_bgr
def _parse_gemini_response(text: str) -> dict:
"""Extract JSON from Gemini response, stripping any accidental markdown."""
text = text.strip()
# Strip markdown code fences if model added them despite instructions
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.MULTILINE)
text = re.sub(r"\s*```$", "", text, flags=re.MULTILINE)
# Find the outermost JSON object
m = re.search(r"\{.*\}", text, re.DOTALL)
if m:
return json.loads(m.group(0))
raise ValueError(f"No JSON found in Gemini response: {text[:300]}")
def _call_gemini_api(api_key: str, pil_img, prompt: str, model_name: str) -> str:
"""Make a single Gemini API call. Raises on error (key pool handles retries)."""
import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold
genai.configure(api_key=api_key)
model = genai.GenerativeModel(model_name)
# Disable all safety filters β HR documents can contain personal info
safety = {
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
}
gen_cfg = genai.GenerationConfig(
temperature=0.0, # deterministic β we want consistent extraction
max_output_tokens=400, # ~300 tokens of JSON + buffer; saves quota
response_mime_type="application/json", # forces valid JSON, no prose
)
response = model.generate_content(
[prompt, pil_img],
safety_settings=safety,
generation_config=gen_cfg,
)
return response.text
def analyze_with_gemini(
image_bgr: np.ndarray,
doc_type: str,
pdf_text: Optional[str] = None,
) -> GeminiResult:
"""Analyze a document image with Gemini Vision.
Args:
image_bgr: OpenCV BGR image (already rendered if from PDF)
doc_type: one of aadhaar|pan|caste|experience|education|resume|general
pdf_text: optional PDF text layer for extra context (truncated to 300 chars)
Returns GeminiResult with fields, forgery assessment, and confidence.
Falls back gracefully if Gemini is unavailable.
"""
from config import GEMINI_ENABLED, GEMINI_MODEL, GEMINI_MAX_IMAGE_DIMENSION
from ml_utils.gemini_key_pool import get_api_keys
if not GEMINI_ENABLED or not get_api_keys():
return GeminiResult(error="Gemini not configured β set GEMINI_API_KEY")
if image_bgr is None or image_bgr.size == 0:
return GeminiResult(error="No image provided to Gemini")
try:
import PIL.Image
# Prepare: resize to max dimension, convert to PIL for Gemini SDK
max_dim = GEMINI_MAX_IMAGE_DIMENSION or 1024
img_resized = _prepare_image(image_bgr, max_dim=max_dim)
_, buf = cv2.imencode(".jpg", img_resized, [cv2.IMWRITE_JPEG_QUALITY, 88])
pil_img = PIL.Image.open(io.BytesIO(buf.tobytes()))
# Build the hyper-granular prompt
prompt = build_prompt(doc_type)
# Prepend PDF text hint if available (uses ~50 tokens, saves analysis errors)
if pdf_text and len(pdf_text.strip()) > 20:
# Only send first 300 chars β enough context without wasting tokens
hint = pdf_text[:300].replace("\n", " ").strip()
prompt = f"[PDF_TEXT_HINT: {hint}]\n\n{prompt}"
# Call with key-pool failover (5 keys = ~5000 free requests/day)
raw_text, key_index, err = call_with_failover(
lambda key: _call_gemini_api(key, pil_img, prompt, GEMINI_MODEL)
)
if err or not raw_text:
return GeminiResult(
error=str(err or "Gemini call failed β all keys exhausted"),
used_gemini=False
)
logger.info("Gemini[key=%s model=%s] raw: %.200s", key_index, GEMINI_MODEL, raw_text)
# Parse the JSON response
parsed = _parse_gemini_response(raw_text)
# Extract fields β remove nulls, "null" strings, and empty values
raw_fields = parsed.get("fields", {}) or {}
fields = {
k: v for k, v in raw_fields.items()
if v is not None and str(v).strip() not in ("null", "", "None", "n/a", "N/A")
}
# Forgery assessment
forgery_data = parsed.get("forgery", {}) or {}
forgery_score = float(forgery_data.get("score", 0) or 0)
forgery_reason = str(forgery_data.get("reason", "") or "")[:150]
# AI extraction confidence
conf_data = parsed.get("ai_confidence", {}) or {}
ai_confidence = float(conf_data.get("score", 50) or 50)
ai_confidence_reason = str(conf_data.get("reason", "") or "")[:150]
# Conservative: only flag as suspicious when score exceeds threshold
# Resumes never get forgery flags
is_suspicious = (forgery_score > 35) and (doc_type != "resume")
if is_suspicious:
logger.info(
"Gemini SUSPICIOUS doc_type=%s score=%.1f reason=%s",
doc_type, forgery_score, forgery_reason
)
else:
logger.info(
"Gemini CLEAN doc_type=%s forgery=%.1f ai_conf=%.1f fields=%s",
doc_type, forgery_score, ai_confidence, list(fields.keys())
)
return GeminiResult(
fields=fields,
forgery_score=round(forgery_score, 1),
forgery_reason=forgery_reason,
ai_confidence=round(ai_confidence, 1),
ai_confidence_reason=ai_confidence_reason,
is_suspicious=is_suspicious,
used_gemini=True,
raw_json=raw_text[:4000], # store for DB, truncated to 4KB
gemini_model=GEMINI_MODEL,
key_index=key_index,
)
except Exception as exc:
logger.warning("Gemini analysis failed: %s", exc, exc_info=True)
return GeminiResult(error=str(exc), used_gemini=False)
def merge_fields(gemini_fields: dict, ocr_fields: dict, doc_type: str) -> dict:
"""Merge Gemini + OCR fields. Gemini wins on conflicts; OCR fills gaps.
This is the 70/30 blend: Gemini is primary (visual AI), OCR is backup.
"""
merged = dict(ocr_fields) # start with OCR as base
for key, value in gemini_fields.items():
if value is not None and str(value).strip() not in ("null", "None", ""):
merged[key] = value # Gemini overrides OCR for this key
# Aadhaar number: always use Gemini's privacy-safe display format
if doc_type == "aadhaar" and "aadhaar_number" in gemini_fields:
merged["aadhaar_number_display"] = gemini_fields["aadhaar_number"]
merged.pop("aadhaar_number", None) # never expose raw number
merged.pop("aadhaar_number_raw", None) # belt and suspenders
return merged
|