Monike123 commited on
Commit
0a78fb1
Β·
1 Parent(s): 57eda57

feat: gemini-3-flash 5-key pool, hyper-granular prompts, updated Dockerfile for HF Spaces

Browse files
Dockerfile CHANGED
@@ -1,23 +1,34 @@
1
  FROM python:3.11-slim
2
 
 
 
 
 
3
  WORKDIR /app
4
 
5
- # System deps for OpenCV, PyMuPDF (libgl1 replaces obsolete libgl1-mesa-glx on Debian Trixie+)
 
 
 
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
- libgl1 libglib2.0-0 libsm6 libxext6 libxrender1 libgomp1 poppler-utils \
 
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
- # Install Python dependencies
11
  COPY requirements.txt .
12
- RUN pip install --no-cache-dir -r requirements.txt
 
13
 
14
- # Copy backend code
15
  COPY . .
16
 
17
- # Create directories
18
  RUN mkdir -p temp_uploads masked_output original_uploads ocr_models
19
 
20
- # HF Spaces uses port 7860
21
  EXPOSE 7860
22
 
23
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
1
  FROM python:3.11-slim
2
 
3
+ # ── Labels required by HF Spaces ──────────────────────────────────────────
4
+ LABEL maintainer="Monike123"
5
+ LABEL description="DocVerify AI β€” HR Document Verification & Extraction"
6
+
7
  WORKDIR /app
8
 
9
+ # ── System dependencies ────────────────────────────────────────────────────
10
+ # libgl1 + libglib2.0-0 β†’ OpenCV
11
+ # poppler-utils β†’ pdf2image (PDF β†’ PNG rendering)
12
+ # libgomp1 β†’ EasyOCR threading
13
  RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ libgl1 libglib2.0-0 libsm6 libxext6 libxrender1 libgomp1 \
15
+ poppler-utils \
16
  && rm -rf /var/lib/apt/lists/*
17
 
18
+ # ── Python dependencies (cached layer β€” only rebuilds on requirements change) ──
19
  COPY requirements.txt .
20
+ RUN pip install --no-cache-dir --upgrade pip \
21
+ && pip install --no-cache-dir -r requirements.txt
22
 
23
+ # ── Application code ──────────────────────────────────────────────────────
24
  COPY . .
25
 
26
+ # ── Runtime directories ────────────────────────────────────────────────────
27
  RUN mkdir -p temp_uploads masked_output original_uploads ocr_models
28
 
29
+ # ── HF Spaces: port MUST be 7860 ──────────────────────────────────────────
30
  EXPOSE 7860
31
 
32
+ # ── Start server ──────────────────────────────────────────────────────────
33
+ # Workers=1 keeps RAM under 8GB HF free limit (EasyOCR alone is ~1.5GB)
34
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -1,25 +1,21 @@
1
  ---
2
- title: DocVerify API
3
- emoji: πŸ“„
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: docker
7
- app_port: 7860
8
  pinned: false
 
 
9
  ---
10
 
11
- # DocVerify API
12
-
13
- FastAPI backend for HR document verification: EasyOCR + Gemini 3 Flash + Supabase persistence.
14
-
15
- ## Secrets (Space Settings)
16
 
17
- | Variable | Description |
18
- | --- | --- |
19
- | `DATABASE_URL` | Supabase PostgreSQL connection string |
20
- | `GEMINI_API_KEYS` | Comma-separated Gemini API keys (failover) |
21
- | `GEMINI_MODEL` | `gemini-3-flash` |
22
- | `CORS_ORIGINS` | Netlify frontend URL |
23
- | `API_KEY` | Optional demo protection |
24
 
25
- See `DEPLOYMENT.md` in the parent repo for full deploy instructions.
 
 
 
 
 
 
1
  ---
2
+ title: DocVerify AI - HR Document Verification
3
+ emoji: ??
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
+ license: mit
9
+ app_port: 7860
10
  ---
11
 
12
+ # DocVerify AI
 
 
 
 
13
 
14
+ AI-powered HR document verification using Gemini 3 Flash vision, EasyOCR, and forensic forgery detection.
 
 
 
 
 
 
15
 
16
+ **API runs on port 7860. Set these secrets in Space Settings:**
17
+ - DATABASE_URL
18
+ - GEMINI_API_KEY
19
+ - GEMINI_API_KEYS (comma-separated failover keys)
20
+ - GEMINI_MODEL=gemini-3-flash
21
+ - CORS_ORIGINS (your Netlify URL)
config.py CHANGED
@@ -122,6 +122,12 @@ FREE_EMAIL_DOMAINS = {"gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "r
122
 
123
  # ── Gemini Vision AI ─────────────────────────────────────────────────────
124
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
 
125
  GEMINI_API_KEYS = [k.strip() for k in os.getenv("GEMINI_API_KEYS", "").split(",") if k.strip()]
 
126
  GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3-flash")
127
  GEMINI_ENABLED = bool(GEMINI_API_KEY or GEMINI_API_KEYS)
 
 
 
 
 
122
 
123
  # ── Gemini Vision AI ─────────────────────────────────────────────────────
124
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
125
+ # Comma-separated failover key pool (primary key always prepended)
126
  GEMINI_API_KEYS = [k.strip() for k in os.getenv("GEMINI_API_KEYS", "").split(",") if k.strip()]
127
+ # gemini-3-flash: 1000 RPD free, 1M context, agentic vision on images
128
  GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3-flash")
129
  GEMINI_ENABLED = bool(GEMINI_API_KEY or GEMINI_API_KEYS)
130
+ # Image sent to Gemini: max dimension (higher = better accuracy, more tokens)
131
+ GEMINI_MAX_IMAGE_DIMENSION = int(os.getenv("GEMINI_MAX_IMAGE_DIMENSION", "1024"))
132
+ # PDF rendering DPI for Gemini image conversion
133
+ GEMINI_PDF_DPI = int(os.getenv("GEMINI_PDF_DPI", "150"))
ml_utils/gemini_analyzer.py CHANGED
@@ -1,4 +1,13 @@
1
- """Gemini Vision AI analyzer β€” token-efficient JSON-only document verification."""
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
@@ -17,9 +26,6 @@ from ml_utils.gemini_prompts import build_prompt
17
 
18
  logger = logging.getLogger("docverify.gemini")
19
 
20
- GEMINI_MAX_IMAGE_DIM = 768
21
- GEMINI_JPEG_QUALITY = 82
22
-
23
 
24
  @dataclass
25
  class GeminiResult:
@@ -36,44 +42,54 @@ class GeminiResult:
36
  key_index: Optional[int] = None
37
 
38
 
39
- def _prepare_image(image_bgr: np.ndarray) -> np.ndarray:
40
- from config import GEMINI_MAX_IMAGE_DIMENSION
41
-
42
  h, w = image_bgr.shape[:2]
43
- max_dim = min(GEMINI_MAX_IMAGE_DIM, GEMINI_MAX_IMAGE_DIMENSION)
44
  if max(h, w) > max_dim:
45
  scale = max_dim / max(h, w)
46
- image_bgr = cv2.resize(image_bgr, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
 
 
 
47
  return image_bgr
48
 
49
 
50
  def _parse_gemini_response(text: str) -> dict:
 
51
  text = text.strip()
52
- text = re.sub(r"^```(?:json)?\s*", "", text)
53
- text = re.sub(r"\s*```$", "", text)
 
 
54
  m = re.search(r"\{.*\}", text, re.DOTALL)
55
  if m:
56
  return json.loads(m.group(0))
57
- raise ValueError(f"No JSON in response: {text[:200]}")
58
 
59
 
60
  def _call_gemini_api(api_key: str, pil_img, prompt: str, model_name: str) -> str:
 
61
  import google.generativeai as genai
62
  from google.generativeai.types import HarmCategory, HarmBlockThreshold
63
 
64
  genai.configure(api_key=api_key)
65
  model = genai.GenerativeModel(model_name)
 
 
66
  safety = {
67
  HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
68
  HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
69
  HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
70
  HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
71
  }
 
72
  gen_cfg = genai.GenerationConfig(
73
- temperature=0.0,
74
- max_output_tokens=256,
75
- response_mime_type="application/json",
76
  )
 
77
  response = model.generate_content(
78
  [prompt, pil_img],
79
  safety_settings=safety,
@@ -87,71 +103,124 @@ def analyze_with_gemini(
87
  doc_type: str,
88
  pdf_text: Optional[str] = None,
89
  ) -> GeminiResult:
90
- from config import GEMINI_ENABLED, GEMINI_MODEL
 
 
 
 
 
 
 
 
 
 
91
  from ml_utils.gemini_key_pool import get_api_keys
92
 
93
  if not GEMINI_ENABLED or not get_api_keys():
94
- return GeminiResult(error="Gemini not configured")
95
 
96
  if image_bgr is None or image_bgr.size == 0:
97
- return GeminiResult(error="No image provided")
98
 
99
  try:
100
  import PIL.Image
101
 
102
- image_bgr = _prepare_image(image_bgr)
103
- _, buf = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, GEMINI_JPEG_QUALITY])
 
 
104
  pil_img = PIL.Image.open(io.BytesIO(buf.tobytes()))
105
 
 
106
  prompt = build_prompt(doc_type)
107
- if pdf_text and len(pdf_text) > 20:
108
- prompt = f"[pdf_hint:{pdf_text[:200].replace(chr(10), ' ')}]\n{prompt}"
109
 
 
 
 
 
 
 
 
110
  raw_text, key_index, err = call_with_failover(
111
  lambda key: _call_gemini_api(key, pil_img, prompt, GEMINI_MODEL)
112
  )
113
 
114
  if err or not raw_text:
115
- return GeminiResult(error=str(err or "Gemini call failed"), used_gemini=False)
116
-
117
- parsed = _parse_gemini_response(raw_text)
118
- fields = parsed.get("fields", {}) or {}
119
- fields = {k: v for k, v in fields.items() if v is not None and str(v) not in ("null", "", "None")}
120
 
121
- forgery = parsed.get("forgery", {}) or {}
122
- forgery_score = float(forgery.get("score", 0))
123
- forgery_reason = str(forgery.get("reason", ""))[:120]
124
 
125
- ai_conf = parsed.get("ai_confidence", {}) or {}
126
- ai_confidence_score = float(ai_conf.get("score", 50))
127
- ai_confidence_reason = str(ai_conf.get("reason", ""))[:120]
128
 
129
- is_suspicious = forgery_score > 35 and doc_type != "resume"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  return GeminiResult(
132
  fields=fields,
133
  forgery_score=round(forgery_score, 1),
134
  forgery_reason=forgery_reason,
135
- ai_confidence=round(ai_confidence_score, 1),
136
  ai_confidence_reason=ai_confidence_reason,
137
  is_suspicious=is_suspicious,
138
  used_gemini=True,
139
- raw_json=raw_text[:4000],
140
  gemini_model=GEMINI_MODEL,
141
  key_index=key_index,
142
  )
143
 
144
  except Exception as exc:
145
- logger.warning("Gemini analysis failed: %s", exc)
146
  return GeminiResult(error=str(exc), used_gemini=False)
147
 
148
 
149
  def merge_fields(gemini_fields: dict, ocr_fields: dict, doc_type: str) -> dict:
150
- merged = dict(ocr_fields)
 
 
 
 
 
151
  for key, value in gemini_fields.items():
152
- if value and str(value).strip() and str(value) not in ("null", "None"):
153
- merged[key] = value
 
 
154
  if doc_type == "aadhaar" and "aadhaar_number" in gemini_fields:
155
  merged["aadhaar_number_display"] = gemini_fields["aadhaar_number"]
156
- merged.pop("aadhaar_number", None)
 
 
157
  return merged
 
1
+ """Gemini Vision AI analyzer β€” token-efficient JSON-only document verification.
2
+
3
+ Key design decisions:
4
+ - gemini-3-flash: 1M context, 1000 RPD free, agentic vision at high media_resolution
5
+ - Single API call returns fields + forgery score + ai_confidence
6
+ - PDFs rendered to PNG image before analysis (never raw PDF bytes)
7
+ - max_output_tokens=400: covers full JSON output with room to spare
8
+ - response_mime_type='application/json' forces valid JSON output
9
+ - All keys rotate via call_with_failover (5-key pool = ~5000 req/day free)
10
+ """
11
 
12
  from __future__ import annotations
13
 
 
26
 
27
  logger = logging.getLogger("docverify.gemini")
28
 
 
 
 
29
 
30
  @dataclass
31
  class GeminiResult:
 
42
  key_index: Optional[int] = None
43
 
44
 
45
+ def _prepare_image(image_bgr: np.ndarray, max_dim: int = 1024) -> np.ndarray:
46
+ """Resize image preserving aspect ratio. gemini-3-flash handles up to 3072px
47
+ but 1024px is optimal for token efficiency at high_res mode."""
48
  h, w = image_bgr.shape[:2]
 
49
  if max(h, w) > max_dim:
50
  scale = max_dim / max(h, w)
51
+ image_bgr = cv2.resize(
52
+ image_bgr, (int(w * scale), int(h * scale)),
53
+ interpolation=cv2.INTER_LANCZOS4
54
+ )
55
  return image_bgr
56
 
57
 
58
  def _parse_gemini_response(text: str) -> dict:
59
+ """Extract JSON from Gemini response, stripping any accidental markdown."""
60
  text = text.strip()
61
+ # Strip markdown code fences if model added them despite instructions
62
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.MULTILINE)
63
+ text = re.sub(r"\s*```$", "", text, flags=re.MULTILINE)
64
+ # Find the outermost JSON object
65
  m = re.search(r"\{.*\}", text, re.DOTALL)
66
  if m:
67
  return json.loads(m.group(0))
68
+ raise ValueError(f"No JSON found in Gemini response: {text[:300]}")
69
 
70
 
71
  def _call_gemini_api(api_key: str, pil_img, prompt: str, model_name: str) -> str:
72
+ """Make a single Gemini API call. Raises on error (key pool handles retries)."""
73
  import google.generativeai as genai
74
  from google.generativeai.types import HarmCategory, HarmBlockThreshold
75
 
76
  genai.configure(api_key=api_key)
77
  model = genai.GenerativeModel(model_name)
78
+
79
+ # Disable all safety filters β€” HR documents can contain personal info
80
  safety = {
81
  HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
82
  HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
83
  HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
84
  HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
85
  }
86
+
87
  gen_cfg = genai.GenerationConfig(
88
+ temperature=0.0, # deterministic β€” we want consistent extraction
89
+ max_output_tokens=400, # ~300 tokens of JSON + buffer; saves quota
90
+ response_mime_type="application/json", # forces valid JSON, no prose
91
  )
92
+
93
  response = model.generate_content(
94
  [prompt, pil_img],
95
  safety_settings=safety,
 
103
  doc_type: str,
104
  pdf_text: Optional[str] = None,
105
  ) -> GeminiResult:
106
+ """Analyze a document image with Gemini Vision.
107
+
108
+ Args:
109
+ image_bgr: OpenCV BGR image (already rendered if from PDF)
110
+ doc_type: one of aadhaar|pan|caste|experience|education|resume|general
111
+ pdf_text: optional PDF text layer for extra context (truncated to 300 chars)
112
+
113
+ Returns GeminiResult with fields, forgery assessment, and confidence.
114
+ Falls back gracefully if Gemini is unavailable.
115
+ """
116
+ from config import GEMINI_ENABLED, GEMINI_MODEL, GEMINI_MAX_IMAGE_DIMENSION
117
  from ml_utils.gemini_key_pool import get_api_keys
118
 
119
  if not GEMINI_ENABLED or not get_api_keys():
120
+ return GeminiResult(error="Gemini not configured β€” set GEMINI_API_KEY")
121
 
122
  if image_bgr is None or image_bgr.size == 0:
123
+ return GeminiResult(error="No image provided to Gemini")
124
 
125
  try:
126
  import PIL.Image
127
 
128
+ # Prepare: resize to max dimension, convert to PIL for Gemini SDK
129
+ max_dim = GEMINI_MAX_IMAGE_DIMENSION or 1024
130
+ img_resized = _prepare_image(image_bgr, max_dim=max_dim)
131
+ _, buf = cv2.imencode(".jpg", img_resized, [cv2.IMWRITE_JPEG_QUALITY, 88])
132
  pil_img = PIL.Image.open(io.BytesIO(buf.tobytes()))
133
 
134
+ # Build the hyper-granular prompt
135
  prompt = build_prompt(doc_type)
 
 
136
 
137
+ # Prepend PDF text hint if available (uses ~50 tokens, saves analysis errors)
138
+ if pdf_text and len(pdf_text.strip()) > 20:
139
+ # Only send first 300 chars β€” enough context without wasting tokens
140
+ hint = pdf_text[:300].replace("\n", " ").strip()
141
+ prompt = f"[PDF_TEXT_HINT: {hint}]\n\n{prompt}"
142
+
143
+ # Call with key-pool failover (5 keys = ~5000 free requests/day)
144
  raw_text, key_index, err = call_with_failover(
145
  lambda key: _call_gemini_api(key, pil_img, prompt, GEMINI_MODEL)
146
  )
147
 
148
  if err or not raw_text:
149
+ return GeminiResult(
150
+ error=str(err or "Gemini call failed β€” all keys exhausted"),
151
+ used_gemini=False
152
+ )
 
153
 
154
+ logger.info("Gemini[key=%s model=%s] raw: %.200s", key_index, GEMINI_MODEL, raw_text)
 
 
155
 
156
+ # Parse the JSON response
157
+ parsed = _parse_gemini_response(raw_text)
 
158
 
159
+ # Extract fields β€” remove nulls, "null" strings, and empty values
160
+ raw_fields = parsed.get("fields", {}) or {}
161
+ fields = {
162
+ k: v for k, v in raw_fields.items()
163
+ if v is not None and str(v).strip() not in ("null", "", "None", "n/a", "N/A")
164
+ }
165
+
166
+ # Forgery assessment
167
+ forgery_data = parsed.get("forgery", {}) or {}
168
+ forgery_score = float(forgery_data.get("score", 0) or 0)
169
+ forgery_reason = str(forgery_data.get("reason", "") or "")[:150]
170
+
171
+ # AI extraction confidence
172
+ conf_data = parsed.get("ai_confidence", {}) or {}
173
+ ai_confidence = float(conf_data.get("score", 50) or 50)
174
+ ai_confidence_reason = str(conf_data.get("reason", "") or "")[:150]
175
+
176
+ # Conservative: only flag as suspicious when score exceeds threshold
177
+ # Resumes never get forgery flags
178
+ is_suspicious = (forgery_score > 35) and (doc_type != "resume")
179
+
180
+ if is_suspicious:
181
+ logger.info(
182
+ "Gemini SUSPICIOUS doc_type=%s score=%.1f reason=%s",
183
+ doc_type, forgery_score, forgery_reason
184
+ )
185
+ else:
186
+ logger.info(
187
+ "Gemini CLEAN doc_type=%s forgery=%.1f ai_conf=%.1f fields=%s",
188
+ doc_type, forgery_score, ai_confidence, list(fields.keys())
189
+ )
190
 
191
  return GeminiResult(
192
  fields=fields,
193
  forgery_score=round(forgery_score, 1),
194
  forgery_reason=forgery_reason,
195
+ ai_confidence=round(ai_confidence, 1),
196
  ai_confidence_reason=ai_confidence_reason,
197
  is_suspicious=is_suspicious,
198
  used_gemini=True,
199
+ raw_json=raw_text[:4000], # store for DB, truncated to 4KB
200
  gemini_model=GEMINI_MODEL,
201
  key_index=key_index,
202
  )
203
 
204
  except Exception as exc:
205
+ logger.warning("Gemini analysis failed: %s", exc, exc_info=True)
206
  return GeminiResult(error=str(exc), used_gemini=False)
207
 
208
 
209
  def merge_fields(gemini_fields: dict, ocr_fields: dict, doc_type: str) -> dict:
210
+ """Merge Gemini + OCR fields. Gemini wins on conflicts; OCR fills gaps.
211
+
212
+ This is the 70/30 blend: Gemini is primary (visual AI), OCR is backup.
213
+ """
214
+ merged = dict(ocr_fields) # start with OCR as base
215
+
216
  for key, value in gemini_fields.items():
217
+ if value is not None and str(value).strip() not in ("null", "None", ""):
218
+ merged[key] = value # Gemini overrides OCR for this key
219
+
220
+ # Aadhaar number: always use Gemini's privacy-safe display format
221
  if doc_type == "aadhaar" and "aadhaar_number" in gemini_fields:
222
  merged["aadhaar_number_display"] = gemini_fields["aadhaar_number"]
223
+ merged.pop("aadhaar_number", None) # never expose raw number
224
+ merged.pop("aadhaar_number_raw", None) # belt and suspenders
225
+
226
  return merged
ml_utils/gemini_key_pool.py CHANGED
@@ -16,8 +16,8 @@ _index = 0
16
  _daily_counts: dict[str, int] = {}
17
  _daily_date: date | None = None
18
 
19
- # Soft warning threshold per key per day (Gemini 3 Flash free ~20 RPD)
20
- DAILY_WARN_THRESHOLD = 15
21
 
22
  _RETRYABLE_MARKERS = (
23
  "429",
@@ -63,7 +63,11 @@ def _is_retryable(exc: Exception) -> bool:
63
 
64
 
65
  def call_with_failover(fn: Callable[[str], T]) -> tuple[T | None, int | None, Exception | None]:
66
- """Try each API key in round-robin order. Returns (result, key_index, last_error)."""
 
 
 
 
67
  keys = get_api_keys()
68
  if not keys:
69
  return None, None, ValueError("No Gemini API keys configured")
@@ -71,22 +75,31 @@ def call_with_failover(fn: Callable[[str], T]) -> tuple[T | None, int | None, Ex
71
  global _index
72
  with _lock:
73
  start = _index % len(keys)
74
- _index += 1
75
 
76
  last_error: Exception | None = None
 
 
77
  for offset in range(len(keys)):
78
  key_idx = (start + offset) % len(keys)
79
  key = keys[key_idx]
 
80
  try:
81
  result = fn(key)
82
  _bump_usage(key)
 
83
  return result, key_idx, None
84
  except Exception as exc:
85
  last_error = exc
86
- if _is_retryable(exc) and offset < len(keys) - 1:
87
- logger.warning("Gemini key %s failed (%s), trying next key", _mask_key(key), exc)
 
 
 
88
  continue
89
- logger.warning("Gemini key %s failed: %s", _mask_key(key), exc)
 
90
  return None, key_idx, exc
91
 
 
92
  return None, None, last_error
 
16
  _daily_counts: dict[str, int] = {}
17
  _daily_date: date | None = None
18
 
19
+ # gemini-3-flash free tier: 1000 RPD per key; warn at 90% usage
20
+ DAILY_WARN_THRESHOLD = 900
21
 
22
  _RETRYABLE_MARKERS = (
23
  "429",
 
63
 
64
 
65
  def call_with_failover(fn: Callable[[str], T]) -> tuple[T | None, int | None, Exception | None]:
66
+ """Try each API key in round-robin order. Returns (result, key_index, last_error).
67
+
68
+ On quota/rate-limit errors: cycles through ALL remaining keys before failing.
69
+ On non-retryable errors: fails immediately (wrong API key, invalid request, etc.).
70
+ """
71
  keys = get_api_keys()
72
  if not keys:
73
  return None, None, ValueError("No Gemini API keys configured")
 
75
  global _index
76
  with _lock:
77
  start = _index % len(keys)
78
+ _index = (_index + 1) % len(keys)
79
 
80
  last_error: Exception | None = None
81
+ tried: list[int] = []
82
+
83
  for offset in range(len(keys)):
84
  key_idx = (start + offset) % len(keys)
85
  key = keys[key_idx]
86
+ tried.append(key_idx)
87
  try:
88
  result = fn(key)
89
  _bump_usage(key)
90
+ logger.debug("Gemini call succeeded with key %d/%d", key_idx + 1, len(keys))
91
  return result, key_idx, None
92
  except Exception as exc:
93
  last_error = exc
94
+ if _is_retryable(exc):
95
+ logger.warning(
96
+ "Gemini key %d/%d quota/rate-limit (%s), trying next",
97
+ key_idx + 1, len(keys), _mask_key(key)
98
+ )
99
  continue
100
+ # Non-retryable (bad key, invalid request, etc.) β€” fail fast
101
+ logger.warning("Gemini key %d/%d non-retryable error: %s", key_idx + 1, len(keys), exc)
102
  return None, key_idx, exc
103
 
104
+ logger.error("All %d Gemini keys exhausted. Last error: %s", len(keys), last_error)
105
  return None, None, last_error
ml_utils/gemini_prompts.py CHANGED
@@ -1,58 +1,176 @@
1
- """Per-document-type Gemini prompt schemas β€” JSON-only, minimal token output."""
2
 
3
- # Explicit field keys per doc type (Gemini must only return these)
 
 
 
 
 
 
 
 
4
  FIELD_SCHEMAS: dict[str, list[str]] = {
5
  "aadhaar": [
6
- "name", "date_of_birth", "gender", "aadhaar_number", "address", "pincode", "state",
 
 
 
 
 
 
7
  ],
8
- "pan": ["name", "father_name", "date_of_birth", "pan_number"],
9
  "caste": [
10
- "person_name", "category", "caste_name", "certificate_number",
11
- "issuing_authority", "issue_date", "state", "district",
 
 
12
  ],
13
  "experience": [
14
- "employee_name", "company_name", "designation", "joining_date",
15
- "relieving_date", "employment_duration", "hr_email",
 
16
  ],
17
  "education": [
18
  "institute_name", "student_name", "degree", "branch",
19
  "roll_number", "passing_year", "percentage_or_grade",
20
  ],
21
  "resume": [
22
- "candidate_name", "email", "phone", "skills",
 
23
  "experience_years", "current_company", "highest_qualification",
24
  ],
25
  "general": ["document_type", "key_info"],
26
  }
27
 
28
- _DOC_HINTS: dict[str, str] = {
29
- "aadhaar": "UIDAI Aadhaar. Genuine: UIDAI logo, 12-digit number, QR. Forgery: paste boundaries, font mismatch on number.",
30
- "pan": "Income Tax PAN. Genuine: Ashoka emblem, AAAAA9999A format. Forgery: wrong format, photo/text resolution mismatch.",
31
- "caste": "Govt caste certificate. Genuine: seal, authority name. Forgery: missing seal, font inconsistency.",
32
- "experience": "Company experience letter. Genuine: letterhead, dates, signature. Forgery: logo paste, date mismatch.",
33
- "education": "Degree/certificate. Genuine: university seal, roll no. Forgery: blurry seal, inserted marks.",
34
- "resume": "CV/resume. No forgery scoring needed β€” extract fields only.",
35
- "general": "Identify document type and extract visible key fields.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
 
38
 
39
  def build_prompt(doc_type: str) -> str:
 
 
 
 
 
40
  keys = FIELD_SCHEMAS.get(doc_type, FIELD_SCHEMAS["general"])
41
- fields_json = ", ".join(f'"{k}":null' for k in keys)
42
- hint = _DOC_HINTS.get(doc_type, _DOC_HINTS["general"])
43
- forgery_rule = (
44
- 'forgery:{"score":0-100,"reason":"max12words"}'
45
- if doc_type != "resume"
46
- else 'forgery:{"score":0,"reason":"n/a"}'
47
- )
48
- return (
49
- f"HR doc verifier. {hint}\n"
50
- f"Return ONLY valid JSON, no markdown:\n"
51
- f'{{"fields":{{{fields_json}}},'
52
- f'{forgery_rule},'
53
- f'"ai_confidence":{{"score":0-100,"reason":"max12words"}}}}\n'
54
- "Rules: forgery 0-15=genuine,16-35=quality issue,36-60=suspicious,61+=likely fake. "
55
- "ai_confidence=extraction certainty. "
56
- "aadhaar_number format XXXX XXXX last4 only. "
57
- "Missing=null. No extra keys. No prose."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  )
 
 
1
+ """Per-document-type Gemini prompts β€” hyper-granular, zero-prose JSON output.
2
 
3
+ Design:
4
+ - Each doc type has visual layout, genuine markers, and specific forgery tells
5
+ - Output is STRICT JSON only β€” no markdown, no prose, no explanations
6
+ - max_output_tokens=400 covers all fields + forgery + confidence scores
7
+ - Field keys are fixed β€” Gemini must not invent new keys
8
+ - Forgery scoring is calibrated with real-world examples
9
+ """
10
+
11
+ # ── Exact field keys per doc type ─────────────────────────────────────────
12
  FIELD_SCHEMAS: dict[str, list[str]] = {
13
  "aadhaar": [
14
+ "name", "date_of_birth", "gender",
15
+ "aadhaar_number", # output as XXXX XXXX last4 β€” never full 12
16
+ "address", "pincode", "state",
17
+ ],
18
+ "pan": [
19
+ "name", "father_name", "date_of_birth",
20
+ "pan_number", # format: AAAAA9999A (5 letters, 4 digits, 1 letter)
21
  ],
 
22
  "caste": [
23
+ "person_name", "category", # SC / ST / OBC / EWS / General
24
+ "caste_name", "certificate_number",
25
+ "issuing_authority", "issue_date",
26
+ "state", "district",
27
  ],
28
  "experience": [
29
+ "employee_name", "company_name", "designation",
30
+ "joining_date", "relieving_date", "employment_duration",
31
+ "hr_email", # company email (used for verification)
32
  ],
33
  "education": [
34
  "institute_name", "student_name", "degree", "branch",
35
  "roll_number", "passing_year", "percentage_or_grade",
36
  ],
37
  "resume": [
38
+ "candidate_name", "email", "phone",
39
+ "skills", # top 5, comma-separated
40
  "experience_years", "current_company", "highest_qualification",
41
  ],
42
  "general": ["document_type", "key_info"],
43
  }
44
 
45
+ # ── Per-doc layout + forgery knowledge base ───────────────────────────────
46
+ _DOC_KNOWLEDGE: dict[str, str] = {
47
+
48
+ "aadhaar": (
49
+ "UIDAI Aadhaar Card issued by Government of India. "
50
+ "LAYOUT: Blue/white gradient card. Top: UIDAI logo (left) + 'ΰ€­ΰ€Ύΰ€°ΰ€€ ΰ€Έΰ€°ΰ€•ΰ€Ύΰ€° / Government of India' (center). "
51
+ "Body: holder photo (left), name, DOB, gender, address (right). Bottom: 12-digit Aadhaar number in groups of 4 (e.g. 1234 5678 9012). QR code bottom-right corner. "
52
+ "GENUINE MARKERS: UIDAI text is embossed/crisp, font is uniform Noto Sans across all fields, QR matches the data, holographic strip visible in physical photos. "
53
+ "FORGERY TELLS (be specific): (1) Aadhaar number region has different JPEG compression block boundaries than surrounding card β€” visible as slight blur or color banding around digits. "
54
+ "(2) Name or DOB text has slightly different font weight/spacing compared to other text on same card. "
55
+ "(3) Photo has different image quality/compression than the rest of the card. "
56
+ "(4) Address text is typed in different font or has inconsistent line spacing. "
57
+ "(5) Background gradient is interrupted or shows color seam near text fields. "
58
+ "(6) QR code is missing, partially obscured, or clearly copy-pasted. "
59
+ "IMPORTANT: Low image quality, watermarks, and lighting reflections are NOT forgery β€” score 5-15 for these."
60
+ ),
61
+
62
+ "pan": (
63
+ "PAN Card issued by Income Tax Department, Government of India. "
64
+ "LAYOUT: Cream/white background, blue header strip. Ashoka Lion Emblem top-left. "
65
+ "'ΰ€†ΰ€―ΰ€•ΰ€° ΰ€΅ΰ€Ώΰ€­ΰ€Ύΰ€— / Income Tax Department' and 'Govt. of India' in header. "
66
+ "Holder photo right side. Name, Father's Name, DOB in center. PAN number bottom-center (format: AAAAA9999A). "
67
+ "Signature strip at bottom. "
68
+ "GENUINE MARKERS: PAN format exactly AAAAA9999A (5 caps, 4 digits, 1 cap), Ashoka emblem clear, holographic strip. "
69
+ "FORGERY TELLS: (1) PAN number format wrong (e.g. all digits, wrong length). "
70
+ "(2) Holder photo has different resolution/compression than card background. "
71
+ "(3) Name/Father name area has cut-paste boundary (visible pixel seam). "
72
+ "(4) Ashoka emblem is blurry while text around it is sharp. "
73
+ "(5) Header text font differs from body text font. "
74
+ "IMPORTANT: Printed PAN cards scanned at low DPI look grainy β€” that is NOT forgery."
75
+ ),
76
+
77
+ "caste": (
78
+ "Indian State Government Caste/Community Certificate. "
79
+ "LAYOUT: Official government letterhead with state emblem top-center. "
80
+ "Certificate number top-right. Body: applicant name, parent name, caste, sub-caste, category (SC/ST/OBC/EWS/General), village/district. "
81
+ "Bottom: Tehsildar/SDM/District Collector designation, official rubber stamp, handwritten signature, date. "
82
+ "GENUINE MARKERS: Government rubber stamp impression (slightly blurry by nature), handwritten signature, official letterhead, unique certificate number. "
83
+ "FORGERY TELLS: (1) Stamp is too perfect/sharp β€” real stamps are imperfect impressions. "
84
+ "(2) Certificate number appears to be typed over different background. "
85
+ "(3) Official title and date in different fonts. "
86
+ "(4) Category field (SC/ST/OBC) appears added on top of existing text. "
87
+ "IMPORTANT: Poor scan quality, skewed paper, and worn stamps on real documents score 0-15."
88
+ ),
89
+
90
+ "experience": (
91
+ "Company Experience/Relieving Letter on official letterhead. "
92
+ "LAYOUT: Company logo + name top. Date top-right. 'To Whom It May Concern' or addressee. "
93
+ "Body: employee name, designation, joining date, relieving date, employment duration, sometimes CTC. "
94
+ "Footer: HR Manager name, designation, company seal (optional), signature. "
95
+ "GENUINE MARKERS: Consistent company letterhead throughout, professional language, company email/website footer. "
96
+ "FORGERY TELLS: (1) Company logo appears at different DPI/compression than letterhead text. "
97
+ "(2) Employee name or date appears in different font/size from surrounding text. "
98
+ "(3) Joining/relieving dates are inconsistent (relieving before joining, future dates). "
99
+ "(4) Signature or seal appears digitally inserted (uniform white box around it). "
100
+ "(5) Company name in header differs from company name in body text. "
101
+ "IMPORTANT: Digital PDFs with consistent fonts are likely genuine β€” score 0-15."
102
+ ),
103
+
104
+ "education": (
105
+ "University/Board Degree Certificate or Marksheet. "
106
+ "LAYOUT: University/Board name + seal top-center. Student photo (for degrees) or absent (marksheets). "
107
+ "Enrollment/Roll number. Programme/Branch. Examination year/passing year. Marks/Grade/Percentage. "
108
+ "Registrar/Controller signature + official seal bottom. "
109
+ "GENUINE MARKERS: University seal is complex (hard to replicate cleanly), embossed or raised seal. "
110
+ "FORGERY TELLS: (1) Percentage/grade appears in different font or color from surrounding marks. "
111
+ "(2) University seal is blurry while surrounding text is sharp (opposite of genuine β€” real seals are slightly blurry). "
112
+ "(3) Roll number or year has different background color (text was inserted). "
113
+ "(4) Student name appears corrected or overwritten. "
114
+ "IMPORTANT: Old paper documents scanned in poor quality are not forgeries β€” score 0-20."
115
+ ),
116
+
117
+ "resume": (
118
+ "Resume/CV β€” user-created document, no forgery scoring needed. "
119
+ "Extract key professional information only. "
120
+ "IMPORTANT: Score forgery=0 always for resumes."
121
+ ),
122
+
123
+ "general": (
124
+ "Unknown document type. First identify what kind of document this is, then extract all visible key-value information. "
125
+ "Check if it appears to be an official government document or a private/company document."
126
+ ),
127
  }
128
 
129
 
130
  def build_prompt(doc_type: str) -> str:
131
+ """Build a hyper-granular, zero-prose prompt for this document type.
132
+
133
+ Target token budget for RESPONSE: ~300 tokens max.
134
+ The model must return ONLY valid JSON with no wrapping or prose.
135
+ """
136
  keys = FIELD_SCHEMAS.get(doc_type, FIELD_SCHEMAS["general"])
137
+ knowledge = _DOC_KNOWLEDGE.get(doc_type, _DOC_KNOWLEDGE["general"])
138
+
139
+ # Build field template β€” null by default, Gemini fills in what it sees
140
+ fields_template = "{" + ", ".join(f'"{k}": null' for k in keys) + "}"
141
+
142
+ # Forgery section differs for resume (never forged)
143
+ if doc_type == "resume":
144
+ forgery_template = '{"score": 0, "reason": "n/a"}'
145
+ forgery_rules = ""
146
+ else:
147
+ forgery_template = '{"score": 0-100, "reason": "max 15 words, specific evidence only"}'
148
+ forgery_rules = (
149
+ "FORGERY SCORE: 0-15=genuine document; 16-35=quality issues only (NOT forgery); "
150
+ "36-60=suspicious (specific evidence required); 61-100=likely forged (clear evidence only). "
151
+ "Only flag as forged if you see SPECIFIC visual evidence listed above. "
152
+ "Low image quality, compression artifacts, and photo angle are NOT forgery. "
153
+ )
154
+
155
+ prompt = (
156
+ f"You are a forensic HR document analyst. Analyze this image of an Indian {doc_type} document.\n"
157
+ f"\n"
158
+ f"DOCUMENT KNOWLEDGE:\n{knowledge}\n"
159
+ f"\n"
160
+ f"OUTPUT RULES (CRITICAL):\n"
161
+ f"1. Return ONLY valid JSON β€” zero markdown, zero prose, zero explanation\n"
162
+ f"2. Use EXACTLY these keys, no additions: {list(keys)}\n"
163
+ f"3. For missing/unclear fields use null\n"
164
+ f"4. aadhaar_number: output as 'XXXX XXXX <last4>' to protect privacy\n"
165
+ f"5. pan_number: output full value (needed for format validation)\n"
166
+ f"6. Dates: preserve exact format shown on document (e.g. '15/03/1990' or '15 Mar 1990')\n"
167
+ f"\n"
168
+ f"{forgery_rules}"
169
+ f"ai_confidence = your certainty that extracted fields are correct (0=nothing readable, 100=all fields clear and certain).\n"
170
+ f"\n"
171
+ f"RETURN THIS EXACT JSON STRUCTURE (fill in values):\n"
172
+ f'{{"fields": {fields_template}, '
173
+ f'"forgery": {forgery_template}, '
174
+ f'"ai_confidence": {{"score": 0-100, "reason": "max 10 words"}}}}'
175
  )
176
+ return prompt