Skip to content

Commit 0787f5b

Browse files
committed
enrich product image
1 parent f08da4c commit 0787f5b

3 files changed

Lines changed: 301 additions & 7 deletions

File tree

app/celery_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"packstack",
2121
broker=REDIS_URL,
2222
backend=REDIS_URL,
23-
include=["tasks.enrich_product", "tasks.enrich_trip"],
23+
include=["tasks.enrich_product", "tasks.enrich_trip", "tasks.catalog_image"],
2424
)
2525

2626
celery_app.conf.update(

app/tasks/catalog_image.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import base64
2+
import logging
3+
import os
4+
import time
5+
6+
import anthropic
7+
import requests
8+
from sqlalchemy import create_engine
9+
from sqlalchemy.orm import Session
10+
from contextlib import contextmanager
11+
12+
from models.base import CatalogProduct
13+
from celery_app import celery_app
14+
from utils.consts import WORKER_DATABASE_URL
15+
16+
logger = logging.getLogger(__name__)
17+
18+
VISION_MODEL = "claude-haiku-4-5-20251001"
19+
SERPER_IMAGES_URL = "https://google.serper.dev/images"
20+
MAX_CANDIDATES = 5
21+
22+
_engine = None
23+
_ai_client = None
24+
25+
_DOWNLOAD_HEADERS = {
26+
"User-Agent": (
27+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
28+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
29+
)
30+
}
31+
32+
_ALLOWED_MEDIA_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# DB
37+
# ---------------------------------------------------------------------------
38+
39+
def _get_engine():
40+
global _engine
41+
if _engine is None:
42+
_engine = create_engine(
43+
WORKER_DATABASE_URL,
44+
pool_size=2,
45+
max_overflow=3,
46+
pool_pre_ping=True,
47+
pool_recycle=300,
48+
)
49+
return _engine
50+
51+
52+
@contextmanager
53+
def _get_session():
54+
engine = _get_engine()
55+
session = Session(engine)
56+
try:
57+
yield session
58+
session.commit()
59+
except Exception:
60+
session.rollback()
61+
raise
62+
finally:
63+
session.close()
64+
65+
66+
# ---------------------------------------------------------------------------
67+
# AI client
68+
# ---------------------------------------------------------------------------
69+
70+
def _get_ai_client() -> anthropic.Anthropic:
71+
global _ai_client
72+
if _ai_client is None:
73+
api_key = os.environ.get("ANTHROPIC_API_KEY")
74+
if not api_key:
75+
raise RuntimeError("ANTHROPIC_API_KEY is not set")
76+
_ai_client = anthropic.Anthropic(api_key=api_key)
77+
return _ai_client
78+
79+
80+
# ---------------------------------------------------------------------------
81+
# Serper image search
82+
# ---------------------------------------------------------------------------
83+
84+
def _search_images(query: str, num: int = 10) -> list[dict]:
85+
api_key = os.environ.get("SERPER_API_KEY")
86+
if not api_key:
87+
raise RuntimeError("SERPER_API_KEY is not set")
88+
89+
response = requests.post(
90+
SERPER_IMAGES_URL,
91+
headers={
92+
"X-API-KEY": api_key,
93+
"Content-Type": "application/json",
94+
},
95+
json={"q": query, "num": num},
96+
timeout=15,
97+
)
98+
response.raise_for_status()
99+
return response.json().get("images", [])
100+
101+
102+
# ---------------------------------------------------------------------------
103+
# Image download
104+
# ---------------------------------------------------------------------------
105+
106+
def _download_images(urls: list[str]) -> list[tuple[str, bytes, str]]:
107+
results = []
108+
for url in urls:
109+
try:
110+
resp = requests.get(
111+
url, headers=_DOWNLOAD_HEADERS, timeout=10, allow_redirects=True
112+
)
113+
if resp.status_code != 200:
114+
continue
115+
116+
content_type = resp.headers.get("Content-Type", "").split(";")[0].strip()
117+
if content_type not in _ALLOWED_MEDIA_TYPES:
118+
if url.lower().endswith((".jpg", ".jpeg")):
119+
content_type = "image/jpeg"
120+
elif url.lower().endswith(".png"):
121+
content_type = "image/png"
122+
elif url.lower().endswith(".webp"):
123+
content_type = "image/webp"
124+
elif url.lower().endswith(".gif"):
125+
content_type = "image/gif"
126+
else:
127+
continue
128+
129+
if len(resp.content) < 1000:
130+
continue
131+
132+
results.append((url, resp.content, content_type))
133+
except requests.RequestException:
134+
continue
135+
return results
136+
137+
138+
# ---------------------------------------------------------------------------
139+
# Vision selection
140+
# ---------------------------------------------------------------------------
141+
142+
_VISION_SYSTEM = (
143+
"You are a product image evaluator for an outdoor gear catalog. "
144+
"You will be shown candidate images and a product description. "
145+
"Your job is to select the single best image that accurately depicts the product.\n\n"
146+
"Prefer images that:\n"
147+
"- Show the actual product clearly (not a person using it in the field)\n"
148+
"- Have a clean, white, or neutral background\n"
149+
"- Show the complete product, not a close-up of a detail\n"
150+
"- Match the specific product described (correct brand, model, color if known)\n\n"
151+
"If NONE of the images are a good match for the product, respond with 0."
152+
)
153+
154+
155+
def _select_best_image(
156+
product_name: str,
157+
candidates: list[tuple[str, bytes, str]],
158+
specs: dict | None = None,
159+
) -> str | None:
160+
if not candidates:
161+
return None
162+
163+
client = _get_ai_client()
164+
165+
content = []
166+
description = f"Product: {product_name}"
167+
if specs:
168+
spec_parts = []
169+
if specs.get("category_suggestion"):
170+
spec_parts.append(f"Category: {specs['category_suggestion']}")
171+
if specs.get("description"):
172+
spec_parts.append(f"Description: {specs['description']}")
173+
if specs.get("weight") and specs.get("weight_unit"):
174+
spec_parts.append(f"Weight: {specs['weight']}{specs['weight_unit']}")
175+
if spec_parts:
176+
description += "\n" + "\n".join(spec_parts)
177+
178+
content.append({"type": "text", "text": description + "\n\nCandidate images:"})
179+
180+
for i, (url, raw_bytes, media_type) in enumerate(candidates, 1):
181+
content.append({"type": "text", "text": f"\nImage {i}:"})
182+
content.append({
183+
"type": "image",
184+
"source": {
185+
"type": "base64",
186+
"media_type": media_type,
187+
"data": base64.b64encode(raw_bytes).decode(),
188+
},
189+
})
190+
191+
content.append({
192+
"type": "text",
193+
"text": (
194+
f"\n\nWhich image number (1-{len(candidates)}) best depicts the product "
195+
"described above? Reply with ONLY the number. If none are a good match, reply with 0."
196+
),
197+
})
198+
199+
for attempt in range(3):
200+
try:
201+
response = client.messages.create(
202+
model=VISION_MODEL,
203+
max_tokens=32,
204+
system=_VISION_SYSTEM,
205+
messages=[{"role": "user", "content": content}],
206+
)
207+
break
208+
except anthropic.RateLimitError:
209+
wait = 2 ** attempt
210+
logger.warning("Rate limited, retrying in %ds", wait)
211+
time.sleep(wait)
212+
except anthropic.APIStatusError as e:
213+
if e.status_code >= 500 and attempt < 2:
214+
time.sleep(2 ** attempt)
215+
else:
216+
raise
217+
else:
218+
raise RuntimeError("Vision API failed after retries")
219+
220+
reply = response.content[0].text.strip()
221+
222+
try:
223+
choice = int(reply)
224+
except ValueError:
225+
logger.warning("Vision model returned non-numeric response: %s", reply)
226+
return None
227+
228+
if choice == 0:
229+
return None
230+
231+
if 1 <= choice <= len(candidates):
232+
return candidates[choice - 1][0]
233+
234+
logger.warning("Vision model returned out-of-range choice: %d", choice)
235+
return None
236+
237+
238+
# ---------------------------------------------------------------------------
239+
# Celery task
240+
# ---------------------------------------------------------------------------
241+
242+
@celery_app.task(bind=True, max_retries=2, default_retry_delay=30)
243+
def find_product_image(self, catalog_product_id: int):
244+
with _get_session() as session:
245+
entry = session.query(CatalogProduct).get(catalog_product_id)
246+
if not entry:
247+
logger.warning("CatalogProduct %d not found", catalog_product_id)
248+
return
249+
250+
if entry.image_url:
251+
logger.info("CatalogProduct %d already has image_url, skipping", catalog_product_id)
252+
return
253+
254+
label = entry.display_name or f"id={catalog_product_id}"
255+
logger.info("Finding image for: %s", label)
256+
257+
query = f"{entry.display_name} product on white background"
258+
259+
try:
260+
results = _search_images(query, num=MAX_CANDIDATES * 2)
261+
except Exception as exc:
262+
logger.exception("Serper search failed for %s", label)
263+
raise self.retry(exc=exc)
264+
265+
if not results:
266+
logger.info("No search results for %s", label)
267+
return
268+
269+
image_urls = [r["imageUrl"] for r in results[:MAX_CANDIDATES * 2] if r.get("imageUrl")]
270+
candidates = _download_images(image_urls)
271+
candidates = candidates[:MAX_CANDIDATES]
272+
273+
if not candidates:
274+
logger.info("No downloadable images for %s", label)
275+
return
276+
277+
logger.info("Downloaded %d candidates for %s", len(candidates), label)
278+
279+
specs = {
280+
"category_suggestion": entry.category_suggestion,
281+
"description": entry.description,
282+
"weight": str(entry.weight) if entry.weight else None,
283+
"weight_unit": entry.weight_unit,
284+
}
285+
286+
try:
287+
image_url = _select_best_image(entry.display_name, candidates, specs)
288+
except Exception as exc:
289+
logger.exception("Vision selection failed for %s", label)
290+
raise self.retry(exc=exc)
291+
292+
if image_url:
293+
entry.image_url = image_url
294+
session.flush()
295+
logger.info("Set image_url for %s: %s", label, image_url)
296+
else:
297+
logger.info("No suitable image found for %s", label)

app/tasks/enrich_product.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from models.base import Brand, Product, ProductVariant, Item, CatalogProduct
1616
from celery_app import celery_app
17+
from tasks.catalog_image import find_product_image
1718
from utils.consts import WORKER_DATABASE_URL
1819

1920
logger = logging.getLogger(__name__)
@@ -184,7 +185,6 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
184185
'(e.g. "Nemo Tensor Insulated site:rei.com" or the product name on Amazon). '
185186
"From the best available product page(s), extract:\n"
186187
"- A product URL (prefer the manufacturer's page if available, otherwise use a retail page)\n"
187-
"- A product image URL (the main product photo, not a lifestyle/hero image)\n"
188188
"- The listed weight and any other specs (R-value, volume, packed size, temperature rating, etc.)\n\n"
189189
"IMPORTANT: A variant is a meaningful product option like size (S/M/L/Regular/Long), color, gender, "
190190
"or volume capacity. Weight measurements (e.g. \"690g\", \"14oz\", \"2lb\"), dimensions, or other specs "
@@ -242,10 +242,6 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
242242
"if manufacturer doesn't sell direct). Null if unknown."
243243
),
244244
},
245-
"image_url": {
246-
"type": ["string", "null"],
247-
"description": "URL of the main product photo. Null if not found.",
248-
},
249245
"description": {
250246
"type": ["string", "null"],
251247
"description": "One-sentence product description.",
@@ -568,7 +564,6 @@ def enrich_product(self, brand_id: int, product_id: int, product_variant_id: int
568564
weight=weight_grams,
569565
weight_unit="g" if weight_grams else None,
570566
product_url=result.get("product_url"),
571-
image_url=result.get("image_url"),
572567
description=result.get("description"),
573568
category_suggestion=result.get("category"),
574569
subcategory=result.get("subcategory"),
@@ -584,3 +579,5 @@ def enrich_product(self, brand_id: int, product_id: int, product_variant_id: int
584579
session.add(entry)
585580
session.commit()
586581
logger.info("Inserted %s (confidence=%.3f)", display_name, confidence)
582+
583+
find_product_image.delay(entry.id)

0 commit comments

Comments
 (0)