|
3 | 3 | import logging |
4 | 4 | import platform |
5 | 5 | import subprocess |
| 6 | +import base64 |
6 | 7 | from datetime import datetime |
7 | 8 | from PIL import Image, ImageFilter, ImageEnhance |
8 | 9 | from typing import Dict, Any, Optional, Tuple |
| 10 | +from openai import OpenAI |
9 | 11 |
|
10 | 12 | # HEIFのインポートを条件付きに |
11 | 13 | try: |
@@ -86,22 +88,37 @@ def setup_tesseract(): |
86 | 88 |
|
87 | 89 |
|
88 | 90 | class ReceiptProcessor: |
89 | | - """統合されたレシート処理クラス - AI-OCRの協調処理""" |
| 91 | +"""Secure receipt processing with AI-OCR Vision and fallback OCR functionality.""" |
| 92 | + |
90 | 93 |
|
91 | 94 | def __init__(self): |
92 | 95 | """Initialize the receipt processor with secure configuration.""" |
93 | 96 | self.openai_available = settings.openai_available |
| 97 | + self.vision_api_available = settings.vision_api_available |
94 | 98 | self.tesseract_available = tesseract_available |
95 | 99 | self.cv2_available = CV2_AVAILABLE |
96 | 100 | self.heif_available = HEIF_AVAILABLE |
97 | 101 |
|
98 | | - # 処理モード設定 |
99 | | - self.processing_mode = self._determine_processing_mode() |
| 102 | + |
100 | 103 |
|
101 | 104 | # OCRプロセッサーの初期化 |
102 | 105 | self.ocr_processor = OCRProcessor(cv2_available=self.cv2_available) |
103 | 106 |
|
104 | | - # AIプロセッサーの初期化 |
| 107 | + # AIプロセッサーの初期化 # 処理モード設定 |
| 108 | + self.processing_mode = self._determine_processing_mode() |
| 109 | + |
| 110 | + # Initialize OpenAI client for Vision API |
| 111 | + if self.vision_api_available: |
| 112 | + try: |
| 113 | + self.openai_client = OpenAI(api_key=settings.openai_api_key) |
| 114 | + logger.info("OpenAI Vision API initialized successfully") |
| 115 | + except Exception as e: |
| 116 | + logger.error(f"Failed to initialize OpenAI Vision API: {e}") |
| 117 | + self.vision_api_available = False |
| 118 | + |
| 119 | + if not self.tesseract_available: |
| 120 | + logger.error("Tesseract OCR is not available. Please install Tesseract OCR.") |
| 121 | + |
105 | 122 | self.ai_processor = None |
106 | 123 | if self.openai_available: |
107 | 124 | try: |
@@ -157,6 +174,139 @@ def _check_tesseract_languages(self): |
157 | 174 | except Exception as e: |
158 | 175 | logger.error(f"Failed to get Tesseract languages: {e}") |
159 | 176 |
|
| 177 | + def _create_prompt_template(self) -> ChatPromptTemplate: |
| 178 | + """Create a secure prompt template for OpenAI.""" |
| 179 | + return ChatPromptTemplate.from_template( |
| 180 | + """ |
| 181 | + 以下は日本のレシートのテキストです。このテキストから以下の情報を抽出してください: |
| 182 | + 1. 日付 (YYYY-MM-DD形式、見つからない場合はnull) |
| 183 | + 2. 店名または会社名 |
| 184 | + 3. 合計金額 (数値のみ、見つからない場合はnull) |
| 185 | + 4. 税抜き価格 (あれば、数値のみ) |
| 186 | + 5. 税込み価格 (あれば、数値のみ) |
| 187 | + |
| 188 | + JSONフォーマットで回答してください: |
| 189 | + {{ |
| 190 | + "date": "YYYY-MM-DD" or null, |
| 191 | + "store_name": "店名", |
| 192 | + "total_amount": 数値 or null, |
| 193 | + "tax_excluded_amount": 数値 or null, |
| 194 | + "tax_included_amount": 数値 or null |
| 195 | + }} |
| 196 | + |
| 197 | + レシートテキスト: |
| 198 | + {text} |
| 199 | + """ |
| 200 | + ) |
| 201 | + |
| 202 | + def _create_vision_prompt(self) -> str: |
| 203 | + """Create a prompt for Vision API OCR.""" |
| 204 | + return """ |
| 205 | + この画像は日本のレシートです。以下の情報を正確に抽出してください: |
| 206 | + |
| 207 | + 1. 日付 (YYYY-MM-DD形式、見つからない場合はnull) |
| 208 | + 2. 店名または会社名 |
| 209 | + 3. 合計金額 (数値のみ、見つからない場合はnull) |
| 210 | + 4. 税抜き価格 (あれば、数値のみ) |
| 211 | + 5. 税込み価格 (あれば、数値のみ) |
| 212 | + |
| 213 | + 以下のJSONフォーマットで回答してください: |
| 214 | + { |
| 215 | + "date": "YYYY-MM-DD" or null, |
| 216 | + "store_name": "店名", |
| 217 | + "total_amount": 数値 or null, |
| 218 | + "tax_excluded_amount": 数値 or null, |
| 219 | + "tax_included_amount": 数値 or null |
| 220 | + } |
| 221 | + |
| 222 | + 注意事項: |
| 223 | + - 日付は必ずYYYY-MM-DD形式に変換してください |
| 224 | + - 金額は数値のみ(カンマや円記号は除く) |
| 225 | + - 税抜き/税込み価格が明記されていない場合はnull |
| 226 | + - 不明な情報はnullとしてください |
| 227 | + """ |
| 228 | + |
| 229 | + def _extract_with_vision_api(self, image_bytes: bytes) -> Dict[str, Any]: |
| 230 | + """Extract receipt information using GPT-4o Vision API.""" |
| 231 | + try: |
| 232 | + # Convert image to base64 |
| 233 | + base64_image = base64.b64encode(image_bytes).decode('utf-8') |
| 234 | + |
| 235 | + logger.info("Sending image to Vision API for OCR...") |
| 236 | + |
| 237 | + # Call Vision API |
| 238 | + response = self.openai_client.chat.completions.create( |
| 239 | + model="gpt-4o", # or "gpt-4o-mini" for cost savings |
| 240 | + messages=[ |
| 241 | + { |
| 242 | + "role": "user", |
| 243 | + "content": [ |
| 244 | + { |
| 245 | + "type": "text", |
| 246 | + "text": self._create_vision_prompt() |
| 247 | + }, |
| 248 | + { |
| 249 | + "type": "image_url", |
| 250 | + "image_url": { |
| 251 | + "url": f"data:image/jpeg;base64,{base64_image}", |
| 252 | + "detail": "high" # Use "high" for better OCR accuracy |
| 253 | + } |
| 254 | + } |
| 255 | + ] |
| 256 | + } |
| 257 | + ], |
| 258 | + max_tokens=1000, |
| 259 | + response_format={"type": "json_object"} # Ensure JSON response |
| 260 | + ) |
| 261 | + |
| 262 | + # Parse the response |
| 263 | + result_text = response.choices[0].message.content |
| 264 | + logger.info(f"Vision API response: {result_text}") |
| 265 | + |
| 266 | + # Parse JSON response |
| 267 | + data = json.loads(result_text) |
| 268 | + |
| 269 | + # Validate and process the data |
| 270 | + processed_data = { |
| 271 | + "date": data.get("date"), |
| 272 | + "store_name": data.get("store_name"), |
| 273 | + "total_amount": float(data.get("total_amount")) if data.get("total_amount") else None, |
| 274 | + "tax_excluded_amount": float(data.get("tax_excluded_amount")) if data.get("tax_excluded_amount") else None, |
| 275 | + "tax_included_amount": float(data.get("tax_included_amount")) if data.get("tax_included_amount") else None, |
| 276 | + "expense_category": None |
| 277 | + } |
| 278 | + |
| 279 | + # Validate required fields |
| 280 | + if not processed_data.get("store_name"): |
| 281 | + return { |
| 282 | + "success": False, |
| 283 | + "message": "Vision APIで店名を抽出できませんでした。", |
| 284 | + "data": None |
| 285 | + } |
| 286 | + |
| 287 | + logger.info("Successfully extracted receipt data with Vision API") |
| 288 | + |
| 289 | + return { |
| 290 | + "success": True, |
| 291 | + "message": "AI-OCR (Vision API)でレシート情報を抽出しました。", |
| 292 | + "data": processed_data |
| 293 | + } |
| 294 | + |
| 295 | + except json.JSONDecodeError as e: |
| 296 | + logger.error(f"Failed to parse JSON from Vision API response: {e}") |
| 297 | + return { |
| 298 | + "success": False, |
| 299 | + "message": "Vision APIのレスポンスが無効でした。", |
| 300 | + "data": None |
| 301 | + } |
| 302 | + except Exception as e: |
| 303 | + logger.error(f"Vision API extraction error: {e}") |
| 304 | + return { |
| 305 | + "success": False, |
| 306 | + "message": f"Vision API処理中にエラーが発生しました: {str(e)}", |
| 307 | + "data": None |
| 308 | + } |
| 309 | + |
160 | 310 | def _convert_heic_to_jpeg(self, image_bytes: bytes) -> bytes: |
161 | 311 | """Convert HEIC/HEIF image to JPEG format.""" |
162 | 312 | if not self.heif_available: |
@@ -321,6 +471,29 @@ def process_image(self, image_bytes: bytes, processing_mode: Optional[str] = Non |
321 | 471 | # 処理モードの決定 |
322 | 472 | if not processing_mode: |
323 | 473 | processing_mode = "auto" |
| 474 | + |
| 475 | + # Try Vision API first if available |
| 476 | + if self.vision_api_available: |
| 477 | + logger.info("Attempting AI-OCR with Vision API...") |
| 478 | + result = self._extract_with_vision_api(image_bytes) |
| 479 | + if result["success"]: |
| 480 | + # 日付が抽出できなかった場合、現在の日時を使用 |
| 481 | + if result["data"] and not result["data"].get("date"): |
| 482 | + result["data"]["date"] = datetime.now().strftime("%Y-%m-%d") |
| 483 | + result["message"] += " 日付は現在の日付で補完しました。" |
| 484 | + return result |
| 485 | + else: |
| 486 | + logger.warning("Vision API failed, falling back to traditional OCR") |
| 487 | + |
| 488 | + # Fall back to traditional OCR processing |
| 489 | + # Tesseractが利用できない場合のエラー |
| 490 | + if not self.tesseract_available: |
| 491 | + return { |
| 492 | + "success": False, |
| 493 | + "message": "OCRエンジンが利用できません。Tesseract OCRをインストールしてください。", |
| 494 | + "data": None |
| 495 | + } |
| 496 | + |
324 | 497 |
|
325 | 498 | # Vision APIモードの場合 |
326 | 499 | if processing_mode == "vision" and self.openai_client: |
|
0 commit comments