FOUNDRY
C8 Platform
← 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