Spaces:
Sleeping
Sleeping
feat: gemini-3-flash 5-key pool, hyper-granular prompts, updated Dockerfile for HF Spaces
Browse files- Dockerfile +19 -8
- README.md +12 -16
- config.py +6 -0
- ml_utils/gemini_analyzer.py +110 -41
- ml_utils/gemini_key_pool.py +20 -7
- ml_utils/gemini_prompts.py +152 -34
Dockerfile
CHANGED
|
@@ -1,23 +1,34 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
# System
|
|
|
|
|
|
|
|
|
|
| 6 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
-
libgl1 libglib2.0-0 libsm6 libxext6 libxrender1 libgomp1
|
|
|
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
-
#
|
| 11 |
COPY requirements.txt .
|
| 12 |
-
RUN pip install --no-cache-dir -
|
|
|
|
| 13 |
|
| 14 |
-
#
|
| 15 |
COPY . .
|
| 16 |
|
| 17 |
-
#
|
| 18 |
RUN mkdir -p temp_uploads masked_output original_uploads ocr_models
|
| 19 |
|
| 20 |
-
# HF Spaces
|
| 21 |
EXPOSE 7860
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: green
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
# DocVerify
|
| 12 |
-
|
| 13 |
-
FastAPI backend for HR document verification: EasyOCR + Gemini 3 Flash + Supabase persistence.
|
| 14 |
-
|
| 15 |
-
## Secrets (Space Settings)
|
| 16 |
|
| 17 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 47 |
return image_bgr
|
| 48 |
|
| 49 |
|
| 50 |
def _parse_gemini_response(text: str) -> dict:
|
|
|
|
| 51 |
text = text.strip()
|
| 52 |
-
|
| 53 |
-
text = re.sub(r"
|
|
|
|
|
|
|
| 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[:
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 103 |
-
|
|
|
|
|
|
|
| 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(
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
fields = {k: v for k, v in fields.items() if v is not None and str(v) not in ("null", "", "None")}
|
| 120 |
|
| 121 |
-
|
| 122 |
-
forgery_score = float(forgery.get("score", 0))
|
| 123 |
-
forgery_reason = str(forgery.get("reason", ""))[:120]
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
ai_confidence_reason = str(ai_conf.get("reason", ""))[:120]
|
| 128 |
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
return GeminiResult(
|
| 132 |
fields=fields,
|
| 133 |
forgery_score=round(forgery_score, 1),
|
| 134 |
forgery_reason=forgery_reason,
|
| 135 |
-
ai_confidence=round(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
for key, value in gemini_fields.items():
|
| 152 |
-
if value and str(value).strip()
|
| 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 |
-
#
|
| 20 |
-
DAILY_WARN_THRESHOLD =
|
| 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
|
| 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)
|
| 87 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 88 |
continue
|
| 89 |
-
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
FIELD_SCHEMAS: dict[str, list[str]] = {
|
| 5 |
"aadhaar": [
|
| 6 |
-
"name", "date_of_birth", "gender",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
],
|
| 8 |
-
"pan": ["name", "father_name", "date_of_birth", "pan_number"],
|
| 9 |
"caste": [
|
| 10 |
-
"person_name", "category",
|
| 11 |
-
"
|
|
|
|
|
|
|
| 12 |
],
|
| 13 |
"experience": [
|
| 14 |
-
"employee_name", "company_name", "designation",
|
| 15 |
-
"
|
|
|
|
| 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",
|
|
|
|
| 23 |
"experience_years", "current_company", "highest_qualification",
|
| 24 |
],
|
| 25 |
"general": ["document_type", "key_info"],
|
| 26 |
}
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
"
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
|
| 39 |
def build_prompt(doc_type: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
keys = FIELD_SCHEMAS.get(doc_type, FIELD_SCHEMAS["general"])
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|