Spaces:
Running
Running
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
- README.md +2 -1
- ai_features.py +97 -88
- 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 |
-
-
|
| 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
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
_GEMINI_OK = bool(_GEMINI_KEY)
|
| 13 |
except ImportError:
|
| 14 |
-
|
|
|
|
| 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 |
-
|
| 25 |
-
You are the Pickle Sommelier β
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
PICKLE: {pickle_name}
|
| 30 |
BRAND: {brand_display}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 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 |
-
|
| 45 |
-
{{
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
text = text.strip()
|
| 80 |
text = re.sub(r"^```(?:json)?\s*\n?", "", text)
|
| 81 |
-
text = re.sub(r"\n?```\s*$",
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def som_placeholder():
|
| 86 |
return """
|
| 87 |
<div class="lb-empty">
|
|
@@ -104,17 +100,7 @@ def scan_placeholder():
|
|
| 104 |
"""
|
| 105 |
|
| 106 |
|
| 107 |
-
|
| 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>'
|
| 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
|
| 169 |
-
return
|
| 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
|
| 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 |
-
|
| 202 |
-
pickle_name
|
| 203 |
-
brand_display
|
| 204 |
-
review_count
|
| 205 |
-
buy_again_pct
|
| 206 |
-
avg_overall
|
| 207 |
-
avg_crunch
|
| 208 |
-
avg_sour
|
| 209 |
-
avg_garlic
|
| 210 |
-
avg_spicy
|
| 211 |
reviews_section = reviews_section,
|
| 212 |
)
|
| 213 |
|
| 214 |
try:
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 270 |
-
return
|
| 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
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
| 1 |
gradio==6.17.3
|
| 2 |
pandas>=1.3.0
|
| 3 |
+
huggingface_hub>=0.26.0
|
| 4 |
+
Pillow>=9.0.0
|