penelopeg commited on
Commit
52b0a98
Β·
1 Parent(s): 1a3167b

switch to HF InferenceClient with tiny models for Tiny Titan badge

Browse files

- Replace google-generativeai with huggingface_hub InferenceClient
- Sommelier: Qwen/Qwen2.5-3B-Instruct (3 B text model)
- Photo scan: Qwen/Qwen2-VL-2B-Instruct (2 B vision model)
- Auth via HF_TOKEN env var instead of GEMINI_API_KEY
- Add achievement:tinytitan tag; swap gemini tag for huggingface

Files changed (3) hide show
  1. README.md +2 -1
  2. ai_features.py +97 -88
  3. requirements.txt +2 -1
README.md CHANGED
@@ -11,9 +11,10 @@ tags:
11
  - build-small-hackathon
12
  - track:thousand-token-wood
13
  - achievement:offbrand
 
14
  - gradio
15
  - pickle
16
- - gemini
17
  - vision
18
  ---
19
 
 
11
  - build-small-hackathon
12
  - track:thousand-token-wood
13
  - achievement:offbrand
14
+ - achievement:tinytitan
15
  - gradio
16
  - pickle
17
+ - huggingface
18
  - vision
19
  ---
20
 
ai_features.py CHANGED
@@ -1,17 +1,19 @@
1
  import os
2
  import re
3
  import json
 
 
4
  import sqlite3
5
  import pandas as pd
6
 
7
  try:
8
- import google.generativeai as genai
9
- _GEMINI_KEY = os.environ.get("GEMINI_API_KEY", "")
10
- if _GEMINI_KEY:
11
- genai.configure(api_key=_GEMINI_KEY)
12
- _GEMINI_OK = bool(_GEMINI_KEY)
13
  except ImportError:
14
- _GEMINI_OK = False
 
15
 
16
  try:
17
  import PIL.Image as _PILImage
@@ -20,68 +22,62 @@ except ImportError:
20
 
21
  from db import DB_PATH, _query_pickle_profiles
22
 
 
 
23
 
24
- _SOMMELIER_PROMPT = """\
25
- You are the Pickle Sommelier β€” a refined expert in pickle culture, fermentation, and brine alchemy.
 
 
26
 
27
- Analyze this pickle and craft a sommelier-style tasting profile from the community review data.
 
28
 
29
  PICKLE: {pickle_name}
30
  BRAND: {brand_display}
31
- TOTAL REVIEWS: {review_count}
32
- BUY AGAIN RATE: {buy_again_pct}%
33
 
34
- AVERAGE SCORES (out of 10):
35
- Overall: {avg_overall}
36
- Crunchiness: {avg_crunch}
37
- Sourness: {avg_sour}
38
- Garlic: {avg_garlic}
39
- Spiciness: {avg_spicy}
40
-
41
- COMMUNITY REVIEW NOTES:
42
  {reviews_section}
43
 
44
- Respond with exactly this JSON object:
45
- {{
46
- "flavor_summary": "2-3 sentences on the overall flavor character and brine profile",
47
- "crunch_description":"1-2 vivid sentences on texture, snap, and structural integrity",
48
- "best_uses": ["use case 1", "use case 2", "use case 3", "use case 4"],
49
- "similar_styles": ["similar pickle style 1", "similar pickle style 2", "similar pickle style 3"],
50
- "tasting_notes": "2-3 playful sentences in wine-sommelier language applied absurdly to pickles",
51
- "verdict": "One punchy sentence: should you buy this pickle again?"
52
- }}
53
-
54
- Return only the JSON β€” no markdown fences, no extra text.
55
- """
56
-
57
- _VISION_PROMPT = """\
58
- You are a pickle product expert examining a photo of a pickle jar.
59
-
60
- Respond with ONLY a JSON object β€” no markdown, no extra text:
61
- {
62
- "brand": "Brand name from the label, or 'Not visible' if unreadable",
63
- "pickle_name": "Full product name from the label (e.g. Kosher Dill Spears, Bread & Butter Chips)",
64
- "style": "Pickle style (Kosher Dill, Bread & Butter, Spicy, Garlic Dill, Polish, Cornichon, etc.)",
65
- "description": "1-2 sentences describing what you see",
66
- "flavor_profile":"Expected flavor based on style, color, brine, and visible spices"
67
- }
68
- """
69
-
70
-
71
- def _no_key_html():
72
- return (
73
- '<div class="som-error">⚠️ <strong>GEMINI_API_KEY</strong> is not set. '
74
- 'Get a free key at <em>aistudio.google.com</em> and set it before restarting.</div>'
75
- )
76
 
 
 
77
 
78
- def _parse_gemini_json(text):
 
 
 
 
79
  text = text.strip()
80
  text = re.sub(r"^```(?:json)?\s*\n?", "", text)
81
- text = re.sub(r"\n?```\s*$", "", text)
82
- return json.loads(text)
 
 
 
 
 
 
83
 
84
 
 
 
 
 
 
 
 
 
 
85
  def som_placeholder():
86
  return """
87
  <div class="lb-empty">
@@ -104,17 +100,7 @@ def scan_placeholder():
104
  """
105
 
106
 
107
- def get_pickle_choices():
108
- df = _query_pickle_profiles()
109
- if df.empty:
110
- return []
111
- choices = []
112
- for _, row in df.iterrows():
113
- b = row["brand"]
114
- label = f"{row['pickle_name']} β€” {b}" if b != "β€”" else row["pickle_name"]
115
- choices.append((label, f"{row['pickle_name']}|||{b}"))
116
- return choices
117
-
118
 
119
  def _render_sommelier_html(pickle_name, brand, review_count, data):
120
  brand_display = brand if brand != "β€”" else ""
@@ -123,7 +109,7 @@ def _render_sommelier_html(pickle_name, brand, review_count, data):
123
  verdict = data.get("verdict", "")
124
  verdict_html = f'<div class="som-verdict">πŸ† {verdict}</div>' if verdict else ""
125
 
126
- uses_html = "".join(f'<span class="som-tag">{u}</span>' for u in data.get("best_uses", []))
127
  similar_html = "".join(f'<span class="som-tag som-tag-alt">{s}</span>' for s in data.get("similar_styles", []))
128
 
129
  return f"""
@@ -165,8 +151,8 @@ def _render_sommelier_html(pickle_name, brand, review_count, data):
165
  def generate_sommelier(pickle_choice):
166
  if not pickle_choice:
167
  return som_placeholder()
168
- if not _GEMINI_OK:
169
- return _no_key_html()
170
 
171
  parts = pickle_choice.split("|||", 1)
172
  pickle_name = parts[0]
@@ -193,34 +179,43 @@ def generate_sommelier(pickle_choice):
193
  avg_sour = round(float(df["sourness"].mean()), 1)
194
  avg_garlic = round(float(df["garlic"].mean()), 1)
195
  avg_spicy = round(float(df.get("spiciness", pd.Series([5])).mean()), 1)
196
- buy_again_pct = int(round(float(df["buy_again"].mean()) * 100, 0))
197
 
198
  texts = [str(t).strip() for t in df["review_text"].tolist() if t and str(t).strip()]
199
  reviews_section = "\n".join(f'β€’ "{t}"' for t in texts) if texts else "No written notes submitted."
200
 
201
- prompt = _SOMMELIER_PROMPT.format(
202
- pickle_name = pickle_name,
203
- brand_display = brand_key if brand_key != "β€”" else "Unknown brand",
204
- review_count = len(df),
205
- buy_again_pct = buy_again_pct,
206
- avg_overall = avg_overall,
207
- avg_crunch = avg_crunch,
208
- avg_sour = avg_sour,
209
- avg_garlic = avg_garlic,
210
- avg_spicy = avg_spicy,
211
  reviews_section = reviews_section,
212
  )
213
 
214
  try:
215
- model = genai.GenerativeModel("gemini-1.5-flash")
216
- response = model.generate_content(prompt)
217
- data = _parse_gemini_json(response.text)
 
 
 
 
 
 
 
218
  except Exception as exc:
219
  return f'<div class="som-error">⚠️ Sommelier is unavailable: {exc}</div>'
220
 
221
  return _render_sommelier_html(pickle_name, brand_key, len(df), data)
222
 
223
 
 
 
224
  def _render_photo_analysis_html(data):
225
  brand = data.get("brand", "β€”")
226
  pickle_name = data.get("pickle_name", "β€”")
@@ -266,16 +261,30 @@ def analyze_pickle_photo(image_path):
266
  """Returns (html, detected_name, detected_brand) for scan-to-rate pre-fill."""
267
  if image_path is None:
268
  return scan_placeholder(), "", ""
269
- if not _GEMINI_OK:
270
- return _no_key_html(), "", ""
271
  if _PILImage is None:
272
  return '<div class="som-error">⚠️ Pillow is required: <code>pip install Pillow</code></div>', "", ""
273
 
274
  try:
275
- img = _PILImage.open(image_path)
276
- model = genai.GenerativeModel("gemini-1.5-flash")
277
- response = model.generate_content([_VISION_PROMPT, img])
278
- data = _parse_gemini_json(response.text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  except Exception as exc:
280
  return f'<div class="som-error">⚠️ Analysis failed: {exc}</div>', "", ""
281
 
 
1
  import os
2
  import re
3
  import json
4
+ import base64
5
+ import io
6
  import sqlite3
7
  import pandas as pd
8
 
9
  try:
10
+ from huggingface_hub import InferenceClient as _InferenceClient
11
+ _HF_TOKEN = os.environ.get("HF_TOKEN", "")
12
+ _client = _InferenceClient(api_key=_HF_TOKEN or None)
13
+ _HF_OK = True
 
14
  except ImportError:
15
+ _HF_OK = False
16
+ _client = None
17
 
18
  try:
19
  import PIL.Image as _PILImage
 
22
 
23
  from db import DB_PATH, _query_pickle_profiles
24
 
25
+ _TEXT_MODEL = "Qwen/Qwen2.5-3B-Instruct" # 3 B params
26
+ _VISION_MODEL = "Qwen/Qwen2-VL-2B-Instruct" # 2 B params
27
 
28
+ _SOMMELIER_SYSTEM = (
29
+ "You are the Pickle Sommelier β€” an expert in pickle culture and brine alchemy. "
30
+ "Reply ONLY with a valid JSON object, no markdown fences, no extra text."
31
+ )
32
 
33
+ _SOMMELIER_USER = """\
34
+ Analyze this pickle and craft a sommelier-style tasting profile from the review data.
35
 
36
  PICKLE: {pickle_name}
37
  BRAND: {brand_display}
38
+ REVIEWS: {review_count} | Buy-again rate: {buy_again_pct}%
39
+ SCORES /10 β€” Overall: {avg_overall} | Crunch: {avg_crunch} | Sour: {avg_sour} | Garlic: {avg_garlic} | Spice: {avg_spicy}
40
 
41
+ COMMUNITY NOTES:
 
 
 
 
 
 
 
42
  {reviews_section}
43
 
44
+ Return exactly this JSON structure:
45
+ {{"flavor_summary":"2-3 sentences on flavor and brine","crunch_description":"1-2 sentences on texture and snap","best_uses":["use1","use2","use3","use4"],"similar_styles":["style1","style2","style3"],"tasting_notes":"2-3 playful wine-sommelier sentences applied absurdly to pickles","verdict":"One punchy buy-it-or-skip-it sentence"}}"""
46
+
47
+ _VISION_SYSTEM = (
48
+ "You are a pickle product expert. "
49
+ "Reply ONLY with a valid JSON object, no markdown fences, no extra text."
50
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
+ _VISION_USER = """\
53
+ Examine this pickle jar photo carefully.
54
 
55
+ Return exactly this JSON structure:
56
+ {{"brand":"brand name from the label or Not visible","pickle_name":"full product name from the label","style":"pickle style e.g. Kosher Dill / Bread & Butter / Spicy / Garlic Dill / Polish / Cornichon","description":"1-2 sentences on what you see","flavor_profile":"expected flavor based on the visible style, color, brine, and spices"}}"""
57
+
58
+
59
+ def _parse_json(text):
60
  text = text.strip()
61
  text = re.sub(r"^```(?:json)?\s*\n?", "", text)
62
+ text = re.sub(r"\n?```\s*$", "", text)
63
+ try:
64
+ return json.loads(text)
65
+ except json.JSONDecodeError:
66
+ match = re.search(r"\{.*\}", text, re.DOTALL)
67
+ if match:
68
+ return json.loads(match.group(0))
69
+ raise
70
 
71
 
72
+ def _no_token_html():
73
+ return (
74
+ '<div class="som-error">⚠️ <strong>HF_TOKEN</strong> is not set. '
75
+ 'Add a free Hugging Face token as a Space secret to enable AI features.</div>'
76
+ )
77
+
78
+
79
+ # ── Shared placeholder HTML ───────────────────────────────────────────────────
80
+
81
  def som_placeholder():
82
  return """
83
  <div class="lb-empty">
 
100
  """
101
 
102
 
103
+ # ── Sommelier ─────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
104
 
105
  def _render_sommelier_html(pickle_name, brand, review_count, data):
106
  brand_display = brand if brand != "β€”" else ""
 
109
  verdict = data.get("verdict", "")
110
  verdict_html = f'<div class="som-verdict">πŸ† {verdict}</div>' if verdict else ""
111
 
112
+ uses_html = "".join(f'<span class="som-tag">{u}</span>' for u in data.get("best_uses", []))
113
  similar_html = "".join(f'<span class="som-tag som-tag-alt">{s}</span>' for s in data.get("similar_styles", []))
114
 
115
  return f"""
 
151
  def generate_sommelier(pickle_choice):
152
  if not pickle_choice:
153
  return som_placeholder()
154
+ if not _HF_OK:
155
+ return _no_token_html()
156
 
157
  parts = pickle_choice.split("|||", 1)
158
  pickle_name = parts[0]
 
179
  avg_sour = round(float(df["sourness"].mean()), 1)
180
  avg_garlic = round(float(df["garlic"].mean()), 1)
181
  avg_spicy = round(float(df.get("spiciness", pd.Series([5])).mean()), 1)
182
+ buy_again_pct = int(round(float(df["buy_again"].mean()) * 100))
183
 
184
  texts = [str(t).strip() for t in df["review_text"].tolist() if t and str(t).strip()]
185
  reviews_section = "\n".join(f'β€’ "{t}"' for t in texts) if texts else "No written notes submitted."
186
 
187
+ user_msg = _SOMMELIER_USER.format(
188
+ pickle_name = pickle_name,
189
+ brand_display = brand_key if brand_key != "β€”" else "Unknown brand",
190
+ review_count = len(df),
191
+ buy_again_pct = buy_again_pct,
192
+ avg_overall = avg_overall,
193
+ avg_crunch = avg_crunch,
194
+ avg_sour = avg_sour,
195
+ avg_garlic = avg_garlic,
196
+ avg_spicy = avg_spicy,
197
  reviews_section = reviews_section,
198
  )
199
 
200
  try:
201
+ response = _client.chat.completions.create(
202
+ model = _TEXT_MODEL,
203
+ messages = [
204
+ {"role": "system", "content": _SOMMELIER_SYSTEM},
205
+ {"role": "user", "content": user_msg},
206
+ ],
207
+ max_tokens = 600,
208
+ temperature = 0.7,
209
+ )
210
+ data = _parse_json(response.choices[0].message.content)
211
  except Exception as exc:
212
  return f'<div class="som-error">⚠️ Sommelier is unavailable: {exc}</div>'
213
 
214
  return _render_sommelier_html(pickle_name, brand_key, len(df), data)
215
 
216
 
217
+ # ── Photo analysis ────────────────────────────────────────────────────────────
218
+
219
  def _render_photo_analysis_html(data):
220
  brand = data.get("brand", "β€”")
221
  pickle_name = data.get("pickle_name", "β€”")
 
261
  """Returns (html, detected_name, detected_brand) for scan-to-rate pre-fill."""
262
  if image_path is None:
263
  return scan_placeholder(), "", ""
264
+ if not _HF_OK:
265
+ return _no_token_html(), "", ""
266
  if _PILImage is None:
267
  return '<div class="som-error">⚠️ Pillow is required: <code>pip install Pillow</code></div>', "", ""
268
 
269
  try:
270
+ img = _PILImage.open(image_path).convert("RGB")
271
+ buf = io.BytesIO()
272
+ img.save(buf, format="JPEG", quality=85)
273
+ b64 = base64.b64encode(buf.getvalue()).decode()
274
+ data_url = f"data:image/jpeg;base64,{b64}"
275
+
276
+ response = _client.chat.completions.create(
277
+ model = _VISION_MODEL,
278
+ messages = [{
279
+ "role": "user",
280
+ "content": [
281
+ {"type": "image_url", "image_url": {"url": data_url}},
282
+ {"type": "text", "text": f"{_VISION_SYSTEM}\n\n{_VISION_USER}"},
283
+ ],
284
+ }],
285
+ max_tokens = 400,
286
+ )
287
+ data = _parse_json(response.choices[0].message.content)
288
  except Exception as exc:
289
  return f'<div class="som-error">⚠️ Analysis failed: {exc}</div>', "", ""
290
 
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  gradio==6.17.3
2
  pandas>=1.3.0
3
- google-generativeai>=0.7.0
 
 
1
  gradio==6.17.3
2
  pandas>=1.3.0
3
+ huggingface_hub>=0.26.0
4
+ Pillow>=9.0.0