import json import os from typing import Any from google import genai from google.genai import types from services.ai_service import gemini_keys, with_gemini_key_fallback OCR_MODEL = "gemini-2.5-flash" DEFAULT_OCR_API_KEY = "" SYSTEM_PROMPT = """ You are a precise business card data extraction assistant. Extract all available information from the business card image and return ONLY a valid JSON object. Use the following schema exactly: { "person": { "first_name": "", "last_name": "", "job_title": "", "email": "", "phone": { "mobile": "", "office": "", "extension": "" }, "social": { "linkedin": "" } }, "company": { "company_name": "", "website": "", "address": { "full_address": "", "street": "", "city": "", "state_province": "", "postal_code": "", "country": "" } }, "metadata": { "confidence_score": 0.0, "unparsed_text": "", "card_language": "" } } Rules: 1. Return ONLY valid JSON. 2. Do NOT wrap the response in markdown or code fences. 3. Do NOT include explanations or commentary. 4. If a field is missing, use an empty string "". 5. confidence_score must be between 0 and 1. 6. Put any text that cannot be mapped to a field into metadata.unparsed_text. 7. Infer card_language using ISO language codes such as "en", "fr", "de", "es", etc. 8. Extract LinkedIn URLs if present. 9. Separate mobile, office, and extension numbers when possible. 10. Preserve all extracted values exactly as written on the card. """ def _get_api_key() -> str: keys = gemini_keys() return keys[0] if keys else DEFAULT_OCR_API_KEY def _parse_json_response(text: str) -> dict[str, Any]: cleaned = text.strip() if cleaned.startswith("```"): cleaned = cleaned.strip("`") if cleaned.lower().startswith("json"): cleaned = cleaned[4:].strip() return json.loads(cleaned) def extract_business_card(image_bytes: bytes, mime_type: str) -> dict[str, Any]: """ Extract structured business-card data from uploaded image bytes. """ if not image_bytes: raise ValueError("No image bytes provided.") api_key = _get_api_key() if not api_key: raise RuntimeError("Set GEMINI_API_KEY_prime_1, GEMINI_API_KEY_prime_2, or GEMINI_API_KEY_prime_3 before using OCR.") def call_ocr(key: str): client = genai.Client(api_key=key) return client.models.generate_content( model=OCR_MODEL, contents=[ types.Part.from_bytes(data=image_bytes, mime_type=mime_type), SYSTEM_PROMPT, ], config=types.GenerateContentConfig(service_tier=types.ServiceTier.STANDARD), ) response_text = "" try: response = with_gemini_key_fallback((), call_ocr) response_text = response.text or "" except Exception as gemini_err: import os import base64 import anthropic claude_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() if not claude_key: raise RuntimeError(f"Gemini OCR failed and ANTHROPIC_API_KEY is not configured. Gemini Error: {gemini_err}") client = anthropic.Anthropic(api_key=claude_key) image_data = base64.standard_b64encode(image_bytes).decode("utf-8") image_block = { "type": "image", "source": { "type": "base64", "media_type": mime_type, "data": image_data, }, } messages = [ { "role": "user", "content": [ image_block, {"type": "text", "text": "Extract all available information from the business card image and return ONLY a valid JSON object."}, ], } ] full_response = "" max_continuations = 5 continuation_count = 0 while True: claude_response = client.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=1024, system=SYSTEM_PROMPT, messages=messages, ) for block in claude_response.content: if block.type == "text": full_response += block.text stop_reason = claude_response.stop_reason if stop_reason == "end_turn": break elif stop_reason == "max_tokens": continuation_count += 1 if continuation_count >= max_continuations: break messages.append({"role": "assistant", "content": claude_response.content}) messages.append({ "role": "user", "content": "Your previous response was cut off. Please continue exactly from where you left off. Do not include markdown or explanations, just the JSON.", }) elif stop_reason == "refusal": raise ValueError("Claude refused to analyze this image due to content policy.") else: break response_text = full_response try: return _parse_json_response(response_text) except json.JSONDecodeError as exc: raise ValueError(f"OCR returned invalid JSON: {response_text}") from exc