ProspectIQ / services /ocr.py
peteparker123
feat: claude fallback, celery fixes, ui updates
0400ba7
Raw
History Blame Contribute Delete
5.64 kB
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