winebox 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- winebox/__init__.py +1 -1
- winebox/config.py +40 -5
- winebox/main.py +48 -1
- winebox/models/user.py +2 -0
- winebox/routers/auth.py +117 -3
- winebox/routers/wines.py +227 -32
- winebox/services/image_storage.py +138 -9
- winebox/services/ocr.py +37 -0
- winebox/services/vision.py +278 -0
- winebox/static/css/style.css +545 -0
- winebox/static/favicon.svg +22 -0
- winebox/static/index.html +233 -2
- winebox/static/js/app.js +583 -8
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/METADATA +37 -1
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/RECORD +18 -16
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/WHEEL +0 -0
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/entry_points.txt +0 -0
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,42 +4,171 @@ import uuid
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
import aiofiles
|
|
7
|
-
from fastapi import UploadFile
|
|
7
|
+
from fastapi import HTTPException, UploadFile, status
|
|
8
8
|
|
|
9
9
|
from winebox.config import settings
|
|
10
10
|
|
|
11
|
+
# Allowed MIME types for image uploads
|
|
12
|
+
ALLOWED_MIME_TYPES = {
|
|
13
|
+
"image/jpeg",
|
|
14
|
+
"image/png",
|
|
15
|
+
"image/gif",
|
|
16
|
+
"image/webp",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# File extension to MIME type mapping
|
|
20
|
+
EXTENSION_MIME_MAP = {
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Magic byte signatures for image formats
|
|
29
|
+
# Each entry is (magic_bytes, offset, detected_extension)
|
|
30
|
+
IMAGE_MAGIC_SIGNATURES = [
|
|
31
|
+
# JPEG: starts with FF D8 FF
|
|
32
|
+
(b"\xff\xd8\xff", 0, ".jpg"),
|
|
33
|
+
# PNG: starts with 89 50 4E 47 0D 0A 1A 0A
|
|
34
|
+
(b"\x89PNG\r\n\x1a\n", 0, ".png"),
|
|
35
|
+
# GIF87a and GIF89a
|
|
36
|
+
(b"GIF87a", 0, ".gif"),
|
|
37
|
+
(b"GIF89a", 0, ".gif"),
|
|
38
|
+
# WebP: starts with RIFF....WEBP
|
|
39
|
+
(b"RIFF", 0, ".webp"), # Additional check for WEBP at offset 8
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_image_type(content: bytes) -> str | None:
|
|
44
|
+
"""Detect image type from file content using magic bytes.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
content: The file content bytes.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The detected extension (e.g., ".jpg") or None if not a valid image.
|
|
51
|
+
"""
|
|
52
|
+
if len(content) < 12:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
for magic, offset, ext in IMAGE_MAGIC_SIGNATURES:
|
|
56
|
+
if content[offset:offset + len(magic)] == magic:
|
|
57
|
+
# Special case for WebP: verify WEBP signature at offset 8
|
|
58
|
+
if ext == ".webp":
|
|
59
|
+
if content[8:12] != b"WEBP":
|
|
60
|
+
continue
|
|
61
|
+
return ext
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FileSizeExceededError(Exception):
|
|
67
|
+
"""Raised when uploaded file exceeds size limit."""
|
|
68
|
+
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InvalidFileTypeError(Exception):
|
|
73
|
+
"""Raised when uploaded file has invalid type."""
|
|
74
|
+
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class InvalidMagicBytesError(Exception):
|
|
79
|
+
"""Raised when file content doesn't match a valid image format."""
|
|
80
|
+
|
|
81
|
+
pass
|
|
82
|
+
|
|
11
83
|
|
|
12
84
|
class ImageStorageService:
|
|
13
85
|
"""Service for storing and managing wine label images."""
|
|
14
86
|
|
|
15
|
-
def __init__(
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
storage_path: Path | None = None,
|
|
90
|
+
max_size_bytes: int | None = None,
|
|
91
|
+
) -> None:
|
|
16
92
|
"""Initialize the image storage service.
|
|
17
93
|
|
|
18
94
|
Args:
|
|
19
95
|
storage_path: Path to store images. Defaults to config setting.
|
|
96
|
+
max_size_bytes: Maximum file size in bytes. Defaults to config setting.
|
|
20
97
|
"""
|
|
21
98
|
self.storage_path = storage_path or settings.image_storage_path
|
|
22
99
|
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
self.max_size_bytes = max_size_bytes or settings.max_upload_size_bytes
|
|
101
|
+
|
|
102
|
+
def _validate_extension(self, filename: str | None) -> str:
|
|
103
|
+
"""Validate and return the file extension.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
filename: The original filename.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Valid extension (e.g., ".jpg").
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
InvalidFileTypeError: If extension is not allowed.
|
|
113
|
+
"""
|
|
114
|
+
if not filename:
|
|
115
|
+
return ".jpg"
|
|
116
|
+
|
|
117
|
+
ext = Path(filename).suffix.lower()
|
|
118
|
+
if ext not in EXTENSION_MIME_MAP:
|
|
119
|
+
raise InvalidFileTypeError(
|
|
120
|
+
f"Invalid file type. Allowed types: {', '.join(EXTENSION_MIME_MAP.keys())}"
|
|
121
|
+
)
|
|
122
|
+
return ext
|
|
23
123
|
|
|
24
124
|
async def save_image(self, upload_file: UploadFile) -> str:
|
|
25
|
-
"""Save an uploaded image file.
|
|
125
|
+
"""Save an uploaded image file with size, type, and content validation.
|
|
26
126
|
|
|
27
127
|
Args:
|
|
28
128
|
upload_file: The uploaded file from FastAPI.
|
|
29
129
|
|
|
30
130
|
Returns:
|
|
31
131
|
The filename of the saved image.
|
|
32
|
-
"""
|
|
33
|
-
# Generate unique filename
|
|
34
|
-
ext = Path(upload_file.filename or "image.jpg").suffix.lower()
|
|
35
|
-
if ext not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
|
|
36
|
-
ext = ".jpg"
|
|
37
132
|
|
|
133
|
+
Raises:
|
|
134
|
+
HTTPException: If file exceeds size limit, has invalid type, or
|
|
135
|
+
content doesn't match a valid image format.
|
|
136
|
+
"""
|
|
137
|
+
# Validate extension first
|
|
138
|
+
try:
|
|
139
|
+
declared_ext = self._validate_extension(upload_file.filename)
|
|
140
|
+
except InvalidFileTypeError as e:
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
143
|
+
detail=str(e),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Read content with size limit check
|
|
147
|
+
content = await upload_file.read()
|
|
148
|
+
if len(content) > self.max_size_bytes:
|
|
149
|
+
max_mb = self.max_size_bytes / (1024 * 1024)
|
|
150
|
+
raise HTTPException(
|
|
151
|
+
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
152
|
+
detail=f"File size exceeds maximum allowed size of {max_mb:.1f} MB",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Validate magic bytes - ensure file content matches a valid image format
|
|
156
|
+
detected_ext = detect_image_type(content)
|
|
157
|
+
if detected_ext is None:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
160
|
+
detail="Invalid file content. File does not appear to be a valid image.",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Use the detected extension (more reliable than declared extension)
|
|
164
|
+
# This prevents attacks where malicious files are renamed to .jpg/.png
|
|
165
|
+
ext = detected_ext
|
|
166
|
+
|
|
167
|
+
# Generate unique filename with detected extension
|
|
38
168
|
filename = f"{uuid.uuid4()}{ext}"
|
|
39
169
|
file_path = self.storage_path / filename
|
|
40
170
|
|
|
41
171
|
# Save file
|
|
42
|
-
content = await upload_file.read()
|
|
43
172
|
async with aiofiles.open(file_path, "wb") as f:
|
|
44
173
|
await f.write(content)
|
|
45
174
|
|
winebox/services/ocr.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""OCR service for extracting text from wine label images."""
|
|
2
2
|
|
|
3
|
+
import io
|
|
3
4
|
import logging
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -66,6 +67,42 @@ class OCRService:
|
|
|
66
67
|
logger.error(f"OCR extraction failed: {e}")
|
|
67
68
|
return ""
|
|
68
69
|
|
|
70
|
+
async def extract_text_from_bytes(self, image_data: bytes) -> str:
|
|
71
|
+
"""Extract text from image bytes without saving to disk.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
image_data: Raw image data as bytes.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Extracted text from the image.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
import pytesseract
|
|
81
|
+
|
|
82
|
+
# Open image from bytes
|
|
83
|
+
image = Image.open(io.BytesIO(image_data))
|
|
84
|
+
|
|
85
|
+
# Preprocess image for better OCR results
|
|
86
|
+
# Convert to grayscale
|
|
87
|
+
if image.mode != "L":
|
|
88
|
+
image = image.convert("L")
|
|
89
|
+
|
|
90
|
+
# Extract text
|
|
91
|
+
text = pytesseract.image_to_string(
|
|
92
|
+
image,
|
|
93
|
+
lang="eng",
|
|
94
|
+
config="--psm 6", # Assume uniform block of text
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return text.strip()
|
|
98
|
+
|
|
99
|
+
except ImportError:
|
|
100
|
+
logger.error("pytesseract is not installed")
|
|
101
|
+
return ""
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error(f"OCR extraction failed: {e}")
|
|
104
|
+
return ""
|
|
105
|
+
|
|
69
106
|
async def extract_text_with_confidence(
|
|
70
107
|
self, image_path: str | Path
|
|
71
108
|
) -> tuple[str, float]:
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Claude Vision service for wine label analysis."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from winebox.config import settings
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
WINE_ANALYSIS_PROMPT = """Analyze this wine label image and extract the following information.
|
|
14
|
+
Return ONLY a valid JSON object with these fields (use null for any field you cannot determine):
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"name": "The wine name/title",
|
|
18
|
+
"winery": "The winery or producer name",
|
|
19
|
+
"vintage": 2020,
|
|
20
|
+
"grape_variety": "The grape variety (e.g., Cabernet Sauvignon, Chardonnay)",
|
|
21
|
+
"region": "The wine region (e.g., Napa Valley, Bordeaux)",
|
|
22
|
+
"country": "The country of origin",
|
|
23
|
+
"alcohol_percentage": 13.5,
|
|
24
|
+
"raw_text": "All readable text from the label, preserving line breaks"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Important:
|
|
28
|
+
- vintage should be a number (year) or null
|
|
29
|
+
- alcohol_percentage should be a number or null
|
|
30
|
+
- Extract ALL visible text for raw_text, including small print
|
|
31
|
+
- If you see multiple wines or labels, focus on the main/primary one
|
|
32
|
+
- Be thorough - wine labels often have text in multiple locations"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ClaudeVisionService:
|
|
36
|
+
"""Service for analyzing wine labels using Claude's vision capabilities."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
"""Initialize the Claude Vision service."""
|
|
40
|
+
self._default_client = None
|
|
41
|
+
|
|
42
|
+
def _get_system_api_key(self) -> str | None:
|
|
43
|
+
"""Get the system-wide API key from settings or environment."""
|
|
44
|
+
return settings.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
|
|
45
|
+
|
|
46
|
+
def _get_client(self, user_api_key: str | None = None):
|
|
47
|
+
"""Get an Anthropic client, using user key if provided, else system key."""
|
|
48
|
+
try:
|
|
49
|
+
import anthropic
|
|
50
|
+
|
|
51
|
+
# Use user's API key if provided, otherwise use system key
|
|
52
|
+
api_key = user_api_key or self._get_system_api_key()
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise ValueError("No Anthropic API key configured")
|
|
55
|
+
|
|
56
|
+
# If using system key, cache the client
|
|
57
|
+
if not user_api_key:
|
|
58
|
+
if self._default_client is None:
|
|
59
|
+
self._default_client = anthropic.Anthropic(api_key=api_key)
|
|
60
|
+
return self._default_client
|
|
61
|
+
|
|
62
|
+
# Create a new client for user-specific key
|
|
63
|
+
return anthropic.Anthropic(api_key=api_key)
|
|
64
|
+
except ImportError:
|
|
65
|
+
logger.error("anthropic package is not installed")
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def client(self):
|
|
70
|
+
"""Lazy-load the default Anthropic client (for backward compatibility)."""
|
|
71
|
+
return self._get_client()
|
|
72
|
+
|
|
73
|
+
def is_available(self, user_api_key: str | None = None) -> bool:
|
|
74
|
+
"""Check if Claude Vision is available.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
user_api_key: Optional user-specific API key to check.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
api_key = user_api_key or self._get_system_api_key()
|
|
81
|
+
return bool(api_key) and settings.use_claude_vision
|
|
82
|
+
except Exception:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
async def analyze_label(
|
|
86
|
+
self,
|
|
87
|
+
image_data: bytes,
|
|
88
|
+
media_type: str = "image/jpeg",
|
|
89
|
+
user_api_key: str | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Analyze a wine label image using Claude Vision.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
image_data: Raw image data as bytes.
|
|
95
|
+
media_type: MIME type of the image (image/jpeg, image/png, etc.)
|
|
96
|
+
user_api_key: Optional user-specific API key.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dictionary with parsed wine information.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
# Encode image to base64
|
|
103
|
+
image_base64 = base64.standard_b64encode(image_data).decode("utf-8")
|
|
104
|
+
|
|
105
|
+
# Get client (uses user key if provided, else system key)
|
|
106
|
+
client = self._get_client(user_api_key)
|
|
107
|
+
|
|
108
|
+
# Call Claude API with vision
|
|
109
|
+
message = client.messages.create(
|
|
110
|
+
model="claude-sonnet-4-20250514",
|
|
111
|
+
max_tokens=1024,
|
|
112
|
+
messages=[
|
|
113
|
+
{
|
|
114
|
+
"role": "user",
|
|
115
|
+
"content": [
|
|
116
|
+
{
|
|
117
|
+
"type": "image",
|
|
118
|
+
"source": {
|
|
119
|
+
"type": "base64",
|
|
120
|
+
"media_type": media_type,
|
|
121
|
+
"data": image_base64,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"type": "text",
|
|
126
|
+
"text": WINE_ANALYSIS_PROMPT,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Extract the response text
|
|
134
|
+
response_text = message.content[0].text
|
|
135
|
+
|
|
136
|
+
# Parse JSON from response
|
|
137
|
+
# Handle case where Claude might wrap JSON in markdown code blocks
|
|
138
|
+
if "```json" in response_text:
|
|
139
|
+
response_text = response_text.split("```json")[1].split("```")[0]
|
|
140
|
+
elif "```" in response_text:
|
|
141
|
+
response_text = response_text.split("```")[1].split("```")[0]
|
|
142
|
+
|
|
143
|
+
result = json.loads(response_text.strip())
|
|
144
|
+
|
|
145
|
+
# Ensure all expected fields exist
|
|
146
|
+
return {
|
|
147
|
+
"name": result.get("name"),
|
|
148
|
+
"winery": result.get("winery"),
|
|
149
|
+
"vintage": result.get("vintage"),
|
|
150
|
+
"grape_variety": result.get("grape_variety"),
|
|
151
|
+
"region": result.get("region"),
|
|
152
|
+
"country": result.get("country"),
|
|
153
|
+
"alcohol_percentage": result.get("alcohol_percentage"),
|
|
154
|
+
"raw_text": result.get("raw_text", ""),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
except json.JSONDecodeError as e:
|
|
158
|
+
logger.error(f"Failed to parse Claude response as JSON: {e}")
|
|
159
|
+
logger.debug(f"Response was: {response_text}")
|
|
160
|
+
return self._empty_result()
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Claude Vision analysis failed: {e}")
|
|
163
|
+
return self._empty_result()
|
|
164
|
+
|
|
165
|
+
async def analyze_labels(
|
|
166
|
+
self,
|
|
167
|
+
front_image_data: bytes,
|
|
168
|
+
back_image_data: bytes | None = None,
|
|
169
|
+
front_media_type: str = "image/jpeg",
|
|
170
|
+
back_media_type: str = "image/jpeg",
|
|
171
|
+
user_api_key: str | None = None,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Analyze front and back wine label images.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
front_image_data: Front label image data.
|
|
177
|
+
back_image_data: Optional back label image data.
|
|
178
|
+
front_media_type: MIME type of front image.
|
|
179
|
+
back_media_type: MIME type of back image.
|
|
180
|
+
user_api_key: Optional user-specific API key.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Combined analysis results.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
# Get client (uses user key if provided, else system key)
|
|
187
|
+
client = self._get_client(user_api_key)
|
|
188
|
+
|
|
189
|
+
# Build message content with images
|
|
190
|
+
content = [
|
|
191
|
+
{
|
|
192
|
+
"type": "image",
|
|
193
|
+
"source": {
|
|
194
|
+
"type": "base64",
|
|
195
|
+
"media_type": front_media_type,
|
|
196
|
+
"data": base64.standard_b64encode(front_image_data).decode("utf-8"),
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"type": "text",
|
|
201
|
+
"text": "Front label:" if back_image_data else WINE_ANALYSIS_PROMPT,
|
|
202
|
+
},
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if back_image_data:
|
|
206
|
+
content.extend([
|
|
207
|
+
{
|
|
208
|
+
"type": "image",
|
|
209
|
+
"source": {
|
|
210
|
+
"type": "base64",
|
|
211
|
+
"media_type": back_media_type,
|
|
212
|
+
"data": base64.standard_b64encode(back_image_data).decode("utf-8"),
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"type": "text",
|
|
217
|
+
"text": "Back label:",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"type": "text",
|
|
221
|
+
"text": WINE_ANALYSIS_PROMPT.replace(
|
|
222
|
+
"this wine label image",
|
|
223
|
+
"these wine label images (front and back)"
|
|
224
|
+
),
|
|
225
|
+
},
|
|
226
|
+
])
|
|
227
|
+
|
|
228
|
+
# Call Claude API
|
|
229
|
+
message = client.messages.create(
|
|
230
|
+
model="claude-sonnet-4-20250514",
|
|
231
|
+
max_tokens=1024,
|
|
232
|
+
messages=[{"role": "user", "content": content}],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
response_text = message.content[0].text
|
|
236
|
+
|
|
237
|
+
# Parse JSON
|
|
238
|
+
if "```json" in response_text:
|
|
239
|
+
response_text = response_text.split("```json")[1].split("```")[0]
|
|
240
|
+
elif "```" in response_text:
|
|
241
|
+
response_text = response_text.split("```")[1].split("```")[0]
|
|
242
|
+
|
|
243
|
+
result = json.loads(response_text.strip())
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
"name": result.get("name"),
|
|
247
|
+
"winery": result.get("winery"),
|
|
248
|
+
"vintage": result.get("vintage"),
|
|
249
|
+
"grape_variety": result.get("grape_variety"),
|
|
250
|
+
"region": result.get("region"),
|
|
251
|
+
"country": result.get("country"),
|
|
252
|
+
"alcohol_percentage": result.get("alcohol_percentage"),
|
|
253
|
+
"raw_text": result.get("raw_text", ""),
|
|
254
|
+
"front_label_text": result.get("raw_text", ""),
|
|
255
|
+
"back_label_text": None, # Combined in raw_text
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
except json.JSONDecodeError as e:
|
|
259
|
+
logger.error(f"Failed to parse Claude response as JSON: {e}")
|
|
260
|
+
return self._empty_result()
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"Claude Vision analysis failed: {e}")
|
|
263
|
+
return self._empty_result()
|
|
264
|
+
|
|
265
|
+
def _empty_result(self) -> dict[str, Any]:
|
|
266
|
+
"""Return an empty result dictionary."""
|
|
267
|
+
return {
|
|
268
|
+
"name": None,
|
|
269
|
+
"winery": None,
|
|
270
|
+
"vintage": None,
|
|
271
|
+
"grape_variety": None,
|
|
272
|
+
"region": None,
|
|
273
|
+
"country": None,
|
|
274
|
+
"alcohol_percentage": None,
|
|
275
|
+
"raw_text": "",
|
|
276
|
+
"front_label_text": "",
|
|
277
|
+
"back_label_text": None,
|
|
278
|
+
}
|