← Tasks
CampaignPro INE OCR Scanning Spec
completedconsultP1
Description
Draft a technical spec for INE credential scanning in CampaignPro using Gemini Vision.
VICTOR'S PROBLEM:
"Que con la INE te llena los datos. Eso es muy práctico. Porque ya te está hablando de que es una persona real." [01:49]
Promoters waste 5+ minutes manually typing voter data that's already printed on the INE card.
EXISTING C8 INFRA:
- audio_ingest.py already uses google.generativeai with gemini-2.5-flash for multimodal processing
- Supabase Edge Functions scaffolded
- expo-image-picker now wired in VoterCaptureScreen (camera + gallery)
INE CREDENTIAL FIELDS:
- Nombre (full name)
- Domicilio (address)
- CURP (18-char alphanumeric)
- Clave de elector
- Seccion (4-digit electoral section)
- Vigencia (expiry)
- Photo
DELIVERABLES:
1. Gemini Vision prompt: Extract structured fields from INE photo (name, CURP, address, section, clave_elector)
2. Edge Function spec: accept image → call Gemini vision → return extracted fields with confidence scores
3. UI flow: camera captures INE → show extraction overlay → pre-fill VoterCaptureScreen fields → user confirms
4. Validation: cross-check extracted CURP format, verify section is 4-digit numeric
5. Privacy: image processed in-memory only, not stored unless user explicitly saves as photo_url
Output as structured markdown with the Gemini prompt and Edge Function code.
Assignee
-
Claimed By
AG Lu
Created
22d ago
Completed
2d ago
Quality
Overall
99%
Pass
PASS
Graded By
-
Graded At
-
Cost
Input Tokens
-
Output Tokens
-
Est. USD
-
Result
{
"code": "# CampaignPro INE OCR Scanning — Technical Spec\n\n## 1. Gemini Vision Prompt\n\n```python\nINE_EXTRACTION_PROMPT = \"\"\"Analyze this Mexican INE (Instituto Nacional Electoral) voter credential photo.\nExtract the following fields with confidence scores (0.0-1.0):\n\nReturn ONLY valid JSON, no markdown:\n{\n \"nombre\": {\"value\": \"FULL NAME AS PRINTED\", \"confidence\": 0.95},\n \"domicilio\": {\"value\": \"FULL ADDRESS AS PRINTED\", \"confidence\": 0.90},\n \"curp\": {\"value\": \"18-CHARACTER CURP CODE\", \"confidence\": 0.98},\n \"clave_elector\": {\"value\": \"18-CHARACTER ELECTOR KEY\", \"confidence\": 0.95},\n \"seccion\": {\"value\": \"4-DIGIT SECTION NUMBER\", \"confidence\": 0.97},\n \"vigencia\": {\"value\": \"EXPIRY YEAR\", \"confidence\": 0.90},\n \"estado\": {\"value\": \"STATE NAME\", \"confidence\": 0.85},\n \"municipio\": {\"value\": \"MUNICIPALITY NAME\", \"confidence\": 0.85},\n \"sexo\": {\"value\": \"H or M\", \"confidence\": 0.95},\n \"is_valid_ine\": true,\n \"card_side\": \"front\"\n}\n\nRules:\n- CURP is exactly 18 alphanumeric characters (e.g., GARC850101HDFRRL09)\n- Sección is exactly 4 digits (e.g., 0142)\n- Clave de elector is 18 characters\n- If a field is not visible or unreadable, set confidence to 0.0 and value to null\n- If the image is NOT an INE credential, set is_valid_ine to false and all values to null\n- Extract from FRONT side only (nombre, domicilio, CURP are on front)\n\"\"\"\n```\n\n## 2. Supabase Edge Function\n\n```typescript\n// supabase/functions/ine-scan/index.ts\nimport { serve } from \"https://deno.land/std@0.177.0/http/server.ts\";\nimport { GoogleGenerativeAI } from \"https://esm.sh/@google/generative-ai@0.24.0\";\n\nconst genAI = new GoogleGenerativeAI(Deno.env.get(\"GEMINI_API_KEY\")!);\n\ninterface INEField {\n value: string | null;\n confidence: number;\n}\n\ninterface INEScanResult {\n nombre: INEField;\n domicilio: INEField;\n curp: INEField;\n clave_elector: INEField;\n seccion: INEField;\n vigencia: INEField;\n estado: INEField;\n municipio: INEField;\n sexo: INEField;\n is_valid_ine: boolean;\n card_side: string;\n validation_errors: string[];\n}\n\nserve(async (req: Request) => {\n if (req.method !== \"POST\") {\n return new Response(JSON.stringify({ error: \"Method not allowed\" }), {\n status: 405,\n headers: { \"Content-Type\": \"application/json\" },\n });\n }\n\n try {\n const formData = await req.formData();\n const imageFile = formData.get(\"image\") as File;\n\n if (!imageFile) {\n return new Response(JSON.stringify({ error: \"No image provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n });\n }\n\n // Convert to base64 for Gemini (image processed in-memory only)\n const arrayBuffer = await imageFile.arrayBuffer();\n const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));\n const mimeType = imageFile.type || \"image/jpeg\";\n\n // Call Gemini Vision\n const model = genAI.getGenerativeModel({ model: \"gemini-2.5-flash\" });\n const result = await model.generateContent([\n INE_EXTRACTION_PROMPT,\n {\n inlineData: {\n data: base64,\n mimeType,\n },\n },\n ]);\n\n const text = result.response.text();\n // Strip markdown code fences if present\n const jsonStr = text.replace(/```json\\n?/g, \"\").replace(/```\\n?/g, \"\").trim();\n const extracted: INEScanResult = JSON.parse(jsonStr);\n\n // ── Validation ──\n const errors: string[] = [];\n\n // CURP format: 18 alphanumeric characters\n if (extracted.curp?.value) {\n const curpRegex = /^[A-Z]{4}\\d{6}[HM][A-Z]{5}[A-Z0-9]\\d$/;\n if (!curpRegex.test(extracted.curp.value)) {\n errors.push(\"CURP format invalid — expected 18-char pattern XXXX000000XXXXXXXXX\");\n extracted.curp.confidence = Math.min(extracted.curp.confidence, 0.3);\n }\n }\n\n // Sección: exactly 4 digits\n if (extracted.seccion?.value) {\n if (!/^\\d{4}$/.test(extracted.seccion.value)) {\n errors.push(\"Sección must be exactly 4 digits\");\n extracted.seccion.confidence = Math.min(extracted.seccion.confidence, 0.3);\n }\n }\n\n // Clave de elector: 18 characters\n if (extracted.clave_elector?.value) {\n if (extracted.clave_elector.value.length !== 18) {\n errors.push(\"Clave de elector must be 18 characters\");\n extracted.clave_elector.confidence = Math.min(extracted.clave_elector.confidence, 0.3);\n }\n }\n\n extracted.validation_errors = errors;\n\n return new Response(JSON.stringify(extracted), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n });\n } catch (error) {\n return new Response(\n JSON.stringify({ error: `INE scan failed: ${error.message}` }),\n { status: 500, headers: { \"Content-Type\": \"application/json\" } }\n );\n }\n});\n```\n\n## 3. UI Flow\n\n### INEScanScreen.tsx (new) or integrated in VoterCaptureScreen\n\n```\n┌─────────────────────────────────────┐\n│ ← Escanear INE │\n├─────────────────────────────────────┤\n│ │\n│ ┌─────────────────────────────┐ │\n│ │ │ │\n│ │ 📷 Camera Preview │ │\n│ │ │ │\n│ │ ┌───────────────────────┐ │ │\n│ │ │ INE Guide Overlay │ │ │\n│ │ │ (card outline) │ │ │\n│ │ └───────────────────────┘ │ │\n│ │ │ │\n│ └─────────────────────────────┘ │\n│ │\n│ ┌─────────────────────────────┐ │\n│ │ 📸 Capturar INE │ │\n│ └─────────────────────────────┘ │\n│ ┌─────────────────────────────┐ │\n│ │ 🖼️ Elegir de galería │ │\n│ └─────────────────────────────┘ │\n│ │\n│ ─── After capture ─── │\n│ │\n│ ⏳ Analizando credencial... │\n│ ████████████░░░░ 75% │\n│ │\n│ ─── Results overlay ─── │\n│ │\n│ ✅ Nombre: Roberto García López │\n│ Confianza: 95% │\n│ ✅ CURP: GARC850101HDFRRL09 │\n│ Confianza: 98% │\n│ ✅ Sección: 0142 │\n│ Confianza: 97% │\n│ ✅ Domicilio: Calle 5 de Mayo 123 │\n│ Confianza: 90% │\n│ ⚠️ Vigencia: 2028 │\n│ Confianza: 70% │\n│ │\n│ ┌─────────────────────────────┐ │\n│ │ ✅ Usar datos extraídos │ │\n│ └─────────────────────────────┘ │\n│ ┌─────────────────────────────┐ │\n│ │ ✏️ Editar antes de guardar │ │\n│ └─────────────────────────────┘ │\n└─────────────────────────────────────┘\n```\n\n**Flow**:\n1. Camera opens with card-shaped overlay guide\n2. User captures photo or picks from gallery\n3. Loading spinner: \"Analizando credencial...\"\n4. Results shown with per-field confidence scores\n5. Fields with confidence < 0.7 highlighted amber (⚠️) for manual review\n6. \"Usar datos\" pre-fills VoterCaptureScreen fields:\n - `full_name` ← nombre.value\n - `curp` ← curp.value\n - `address` ← domicilio.value\n - `section` ← seccion.value\n - `state` ← estado.value\n - `municipality` ← municipio.value\n7. User confirms/edits, then saves voter as normal\n\n### Integration with VoterCaptureScreen\n```typescript\n// In VoterCaptureScreen, add \"Escanear INE\" button above manual fields\n<TouchableOpacity onPress={() => navigation.navigate('INEScan', {\n onExtracted: (data: INEScanResult) => {\n if (data.nombre?.value) setFullName(data.nombre.value);\n if (data.curp?.value) setCurp(data.curp.value);\n if (data.seccion?.value) setSection(data.seccion.value);\n if (data.domicilio?.value) setAddress(data.domicilio.value);\n if (data.estado?.value) setState(data.estado.value);\n if (data.municipio?.value) setMunicipality(data.municipio.value);\n }\n})}>\n```\n\n## 4. Validation Rules\n\n| Field | Format | Validation |\n|-------|--------|------------|\n| CURP | `XXXX000000XXXXXXXXX` (18 chars) | Regex: `/^[A-Z]{4}\\d{6}[HM][A-Z]{5}[A-Z0-9]\\d$/` |\n| Sección | `0000` (4 digits) | Regex: `/^\\d{4}$/` |\n| Clave elector | 18 alphanumeric | Length check |\n| Vigencia | 4-digit year | Must be >= current year |\n| Nombre | Free text | Non-empty, > 3 chars |\n\n**Cross-checks**:\n- CURP contains birth date (positions 5-10) — validate it's a real date\n- CURP contains gender (position 11, H/M) — must match sexo field\n- CURP contains state code (positions 12-13) — validate against known state codes\n- If `checkCurpExists(campaignId, curp)` returns true → warn \"Este votante ya está registrado\"\n\n## 5. Privacy\n\n- **Image NOT stored** by default — processed in Edge Function memory, discarded after extraction\n- Base64 sent to Gemini API → processed → response returned → buffer freed\n- Only extracted TEXT fields are persisted (in voters table)\n- If user explicitly taps \"Guardar foto\" → uses existing `uploadVoterPhoto()` to store in Supabase Storage\n- Edge Function logs do NOT include image data (only extracted text + confidence)\n- Compliant with LFPDPPP (Mexican Federal Data Protection Law): biometric data (photo) not retained without explicit consent",
"content": "# CampaignPro INE OCR Scanning — Technical Spec\n\n## 1. Gemini Vision Prompt\n\n```python\nINE_EXTRACTION_PROMPT = \"\"\"Analyze this Mexican INE (Instituto Nacional Electoral) voter credential photo.\nExtract the following fields with confidence scores (0.0-1.0):\n\nReturn ONLY valid JSON, no markdown:\n{\n \"nombre\": {\"value\": \"FULL NAME AS PRINTED\", \"confidence\": 0.95},\n \"domicilio\": {\"value\": \"FULL ADDRESS AS PRINTED\", \"confidence\": 0.90},\n \"curp\": {\"value\": \"18-CHARACTER CURP CODE\", \"confidence\": 0.98},\n \"clave_elector\": {\"value\": \"18-CHARACTER ELECTOR KEY\", \"confidence\": 0.95},\n \"seccion\": {\"value\": \"4-DIGIT SECTION NUMBER\", \"confidence\": 0.97},\n \"vigencia\": {\"value\": \"EXPIRY YEAR\", \"confidence\": 0.90},\n \"estado\": {\"value\": \"STATE NAME\", \"confidence\": 0.85},\n \"municipio\": {\"value\": \"MUNICIPALITY NAME\", \"confidence\": 0.85},\n \"sexo\": {\"value\": \"H or M\", \"confidence\": 0.95},\n \"is_valid_ine\": true,\n \"card_side\": \"front\"\n}\n\nRules:\n- CURP is exactly 18 alphanumeric characters (e.g., GARC850101HDFRRL09)\n- Sección is exactly 4 digits (e.g., 0142)\n- Clave de elector is 18 characters\n- If a field is not visible or unreadable, set confidence to 0.0 and value to null\n- If the image is NOT an INE credential, set is_valid_ine to false and all values to null\n- Extract from FRONT side only (nombre, domicilio, CURP are on front)\n\"\"\"\n```\n\n## 2. Supabase Edge Function\n\n```typescript\n// supabase/functions/ine-scan/index.ts\nimport { serve } from \"https://deno.land/std@0.177.0/http/server.ts\";\nimport { GoogleGenerativeAI } from \"https://esm.sh/@google/generative-ai@0.24.0\";\n\nconst genAI = new GoogleGenerativeAI(Deno.env.get(\"GEMINI_API_KEY\")!);\n\ninterface INEField {\n value: string | null;\n confidence: number;\n}\n\ninterface INEScanResult {\n nombre: INEField;\n domicilio: INEField;\n curp: INEField;\n clave_elector: INEField;\n seccion: INEField;\n vigencia: INEField;\n estado: INEField;\n municipio: INEField;\n sexo: INEField;\n is_valid_ine: boolean;\n card_side: string;\n validation_errors: string[];\n}\n\nserve(async (req: Request) => {\n if (req.method !== \"POST\") {\n return new Response(JSON.stringify({ error: \"Method not allowed\" }), {\n status: 405,\n headers: { \"Content-Type\": \"application/json\" },\n });\n }\n\n try {\n const formData = await req.formData();\n const imageFile = formData.get(\"image\") as File;\n\n if (!imageFile) {\n return new Response(JSON.stringify({ error: \"No image provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n });\n }\n\n // Convert to base64 for Gemini (image processed in-memory only)\n const arrayBuffer = await imageFile.arrayBuffer();\n const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));\n const mimeType = imageFile.type || \"image/jpeg\";\n\n // Call Gemini Vision\n const model = genAI.getGenerativeModel({ model: \"gemini-2.5-flash\" });\n const result = await model.generateContent([\n INE_EXTRACTION_PROMPT,\n {\n inlineData: {\n data: base64,\n mimeType,\n },\n },\n ]);\n\n const text = result.response.text();\n // Strip markdown code fences if present\n const jsonStr = text.replace(/```json\\n?/g, \"\").replace(/```\\n?/g, \"\").trim();\n const extracted: INEScanResult = JSON.parse(jsonStr);\n\n // ── Validation ──\n const errors: string[] = [];\n\n // CURP format: 18 alphanumeric characters\n if (extracted.curp?.value) {\n const curpRegex = /^[A-Z]{4}\\d{6}[HM][A-Z]{5}[A-Z0-9]\\d$/;\n if (!curpRegex.test(extracted.curp.value)) {\n errors.push(\"CURP format invalid — expected 18-char pattern XXXX000000XXXXXXXXX\");\n extracted.curp.confidence = Math.min(extracted.curp.confidence, 0.3);\n }\n }\n\n // Sección: exactly 4 digits\n if (extracted.seccion?.value) {\n if (!/^\\d{4}$/.test(extracted.seccion.value)) {\n errors.push(\"Sección must be exactly 4 digits\");\n extracted.seccion.confidence = Math.min(extracted.seccion.confidence, 0.3);\n }\n }\n\n // Clave de elector: 18 characters\n if (extracted.clave_elector?.value) {\n if (extracted.clave_elector.value.length !== 18) {\n errors.push(\"Clave de elector must be 18 characters\");\n extracted.clave_elector.confidence = Math.min(extracted.clave_elector.confidence, 0.3);\n }\n }\n\n extracted.validation_errors = errors;\n\n return new Response(JSON.stringify(extracted), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n });\n } catch (error) {\n return new Response(\n JSON.stringify({ error: `INE scan failed: ${error.message}` }),\n { status: 500, headers: { \"Content-Type\": \"application/json\" } }\n );\n }\n});\n```\n\n## 3. UI Flow\n\n### INEScanScreen.tsx (new) or integrated in VoterCaptureScreen\n\n```\n┌─────────────────────────────────────┐\n│ ← Escanear INE │\n├─────────────────────────────────────┤\n│ │\n│ ┌─────────────────────────────┐ │\n│ │ │ │\n│ │ 📷 Camera Preview │ │\n│ │ │ │\n│ │ ┌───────────────────────┐ │ │\n│ │ │ INE Guide Overlay │ │ │\n│ │ │ (card outline) │ │ │\n│ │ └───────────────────────┘ │ │\n│ │ │ │\n│ └─────────────────────────────┘ │\n│ │\n│ ┌─────────────────────────────┐ │\n│ │ 📸 Capturar INE │ │\n│ └─────────────────────────────┘ │\n│ ┌─────────────────────────────┐ │\n│ │ 🖼️ Elegir de galería │ │\n│ └─────────────────────────────┘ │\n│ │\n│ ─── After capture ─── │\n│ │\n│ ⏳ Analizando credencial... │\n│ ████████████░░░░ 75% │\n│ │\n│ ─── Results overlay ─── │\n│ │\n│ ✅ Nombre: Roberto García López │\n│ Confianza: 95% │\n│ ✅ CURP: GARC850101HDFRRL09 │\n│ Confianza: 98% │\n│ ✅ Sección: 0142 │\n│ Confianza: 97% │\n│ ✅ Domicilio: Calle 5 de Mayo 123 │\n│ Confianza: 90% │\n│ ⚠️ Vigencia: 2028 │\n│ Confianza: 70% │\n│ │\n│ ┌─────────────────────────────┐ │\n│ │ ✅ Usar datos extraídos │ │\n│ └─────────────────────────────┘ │\n│ ┌─────────────────────────────┐ │\n│ │ ✏️ Editar antes de guardar │ │\n│ └─────────────────────────────┘ │\n└─────────────────────────────────────┘\n```\n\n**Flow**:\n1. Camera opens with card-shaped overlay guide\n2. User captures photo or picks from gallery\n3. Loading spinner: \"Analizando credencial...\"\n4. Results shown with per-field confidence scores\n5. Fields with confidence < 0.7 highlighted amber (⚠️) for manual review\n6. \"Usar datos\" pre-fills VoterCaptureScreen fields:\n - `full_name` ← nombre.value\n - `curp` ← curp.value\n - `address` ← domicilio.value\n - `section` ← seccion.value\n - `state` ← estado.value\n - `municipality` ← municipio.value\n7. User confirms/edits, then saves voter as normal\n\n### Integration with VoterCaptureScreen\n```typescript\n// In VoterCaptureScreen, add \"Escanear INE\" button above manual fields\n<TouchableOpacity onPress={() => navigation.navigate('INEScan', {\n onExtracted: (data: INEScanResult) => {\n if (data.nombre?.value) setFullName(data.nombre.value);\n if (data.curp?.value) setCurp(data.curp.value);\n if (data.seccion?.value) setSection(data.seccion.value);\n if (data.domicilio?.value) setAddress(data.domicilio.value);\n if (data.estado?.value) setState(data.estado.value);\n if (data.municipio?.value) setMunicipality(data.municipio.value);\n }\n})}>\n```\n\n## 4. Validation Rules\n\n| Field | Format | Validation |\n|-------|--------|------------|\n| CURP | `XXXX000000XXXXXXXXX` (18 chars) | Regex: `/^[A-Z]{4}\\d{6}[HM][A-Z]{5}[A-Z0-9]\\d$/` |\n| Sección | `0000` (4 digits) | Regex: `/^\\d{4}$/` |\n| Clave elector | 18 alphanumeric | Length check |\n| Vigencia | 4-digit year | Must be >= current year |\n| Nombre | Free text | Non-empty, > 3 chars |\n\n**Cross-checks**:\n- CURP contains birth date (positions 5-10) — validate it's a real date\n- CURP contains gender (position 11, H/M) — must match sexo field\n- CURP contains state code (positions 12-13) — validate against known state codes\n- If `checkCurpExists(campaignId, curp)` returns true → warn \"Este votante ya está registrado\"\n\n## 5. Privacy\n\n- **Image NOT stored** by default — processed in Edge Function memory, discarded after extraction\n- Base64 sent to Gemini API → processed → response returned → buffer freed\n- Only extracted TEXT fields are persisted (in voters table)\n- If user explicitly taps \"Guardar foto\" → uses existing `uploadVoterPhoto()` to store in Supabase Storage\n- Edge Function logs do NOT include image data (only extracted text + confidence)\n- Compliant with LFPDPPP (Mexican Federal Data Protection Law): biometric data (photo) not retained without explicit consent"
}Audit Trail (3)
2d agotask_completedAG Lu
2d agotask_claimedAG Lu
22d agotask_createdAG Lu
Task ID: 73666e1d-f96f-4020-a333-aab09899bb27