Tolani Akinola commited on
Commit
c257b79
·
1 Parent(s): 7fd33de

Update streamlit_app.py

Browse files

new architecture with lots of upgrade to the app prior to moving building to cursor to build ui ux

Files changed (1) hide show
  1. streamlit_app.py +517 -269
streamlit_app.py CHANGED
@@ -1,308 +1,556 @@
1
- # --- Care Count Inventory (OCR-only suggestion via Hugging Face Inference API) ---
2
-
3
- import os
4
- os.environ.setdefault("HOME", "/tmp") # avoid '/.streamlit' permission issue on Spaces
5
- os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
6
-
7
- import io
8
- import time
9
- import base64
10
- import json
 
 
 
 
 
 
11
  import requests
12
  import pandas as pd
13
  import streamlit as st
14
  from PIL import Image, ImageOps, ImageEnhance
15
  from supabase import create_client, Client
16
 
17
- try:
18
- from huggingface_hub import InferenceClient
19
- except Exception:
20
- InferenceClient = None # we'll fall back to raw HTTP
21
-
22
- # ------------------------ Page ------------------------
23
- st.set_page_config(page_title="Care Count Inventory", layout="centered")
24
- st.title("📦 Care Count Inventory")
25
- st.caption("OCR-assisted inventory logging with Supabase (Hugging Face Inference API)")
26
 
27
- # ------------------------ Secrets & clients ------------------------
28
- def get_secret(name: str, default: str | None = None) -> str | None:
29
- # Reads from env/variables first, then from st.secrets
30
- return os.getenv(name) or st.secrets.get(name, default)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  SUPABASE_URL = get_secret("SUPABASE_URL")
33
- SUPABASE_KEY = get_secret("SUPABASE_KEY")
34
  if not SUPABASE_URL or not SUPABASE_KEY:
35
- st.error("Missing Supabase creds. Add SUPABASE_URL & SUPABASE_KEY in Settings → Secrets.")
36
  st.stop()
37
 
38
  sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
39
 
40
- HF_TOKEN = get_secret("HF_TOKEN") # optional but recommended for reliability/rate limits
41
- OCR_MODEL = (os.getenv("OCR_MODEL") or st.secrets.get("OCR_MODEL")
42
- or "microsoft/trocr-large-printed").strip().strip('"').strip("'")
 
 
 
 
43
 
44
- # ------------------------ Image utilities ------------------------
45
- def _to_png_bytes(img: Image.Image) -> bytes:
46
- b = io.BytesIO()
47
- img.save(b, format="PNG")
48
- return b.getvalue()
49
 
50
- def preprocess_for_label(img: Image.Image) -> Image.Image:
51
- """Lighten/contrast + gentle resize for mobile, improves label legibility."""
52
- img = img.convert("RGB")
53
- # Resize longest side to ~1024px (helps OCR without huge payloads)
54
- w, h = img.size
55
- scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
56
- if scale < 1.0:
57
- img = img.resize((int(w * scale), int(h * scale)))
58
- img = ImageOps.autocontrast(img, cutoff=2)
59
- img = ImageEnhance.Brightness(img).enhance(1.12)
60
- img = ImageEnhance.Contrast(img).enhance(1.08)
61
- sharp = ImageEnhance.Sharpness(img).enhance(1.2)
62
- return sharp
63
-
64
- # ------------------------ OCR helpers ------------------------
65
- @st.cache_resource(show_spinner=False)
66
- def _hf_client():
67
- if InferenceClient is None:
68
- return None
69
  try:
70
- return InferenceClient(token=HF_TOKEN)
71
  except Exception:
72
- return None
73
-
74
- def _ocr_via_client(img: Image.Image) -> tuple[str, str | None]:
75
- """
76
- Preferred path: huggingface_hub.InferenceClient.image_to_text
77
- Returns (text, error)
78
- """
79
- client = _hf_client()
80
- if client is None:
81
- return "", "HF client unavailable"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  try:
83
- text = client.image_to_text(image=_to_png_bytes(img), model=OCR_MODEL)
84
- if isinstance(text, dict):
85
- # Some backends return {"generated_text": "..."}
86
- text = text.get("generated_text", "")
87
- return (text or "").strip(), None
88
- except Exception as e:
89
- return "", f"client error: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- def _ocr_via_http(img: Image.Image) -> tuple[str, str | None]:
92
- """
93
- Fallback: raw HTTP to Inference API.
94
- TrOCR (image-to-text) accepts base64 in JSON for Inference API.
95
- """
96
  try:
97
- url = f"https://api-inference.huggingface.co/models/{OCR_MODEL}"
98
- headers = {"Accept": "application/json"}
99
- if HF_TOKEN:
100
- headers["Authorization"] = f"Bearer {HF_TOKEN}"
101
-
102
- img_b64 = base64.b64encode(_to_png_bytes(img)).decode("utf-8")
103
- payload = {"inputs": img_b64}
104
-
105
- r = requests.post(url, headers=headers, json=payload, timeout=60)
106
- if r.status_code in (503, 524):
107
- return "", f"model loading ({r.status_code})"
108
- if r.status_code == 404:
109
- return "", "model not found (404)"
110
- if r.status_code != 200:
111
- return "", f"HTTP {r.status_code}: {r.text[:200]}"
112
-
113
- out = r.json()
114
- # TrOCR usually returns {"generated_text": "..."} or plain string
115
- if isinstance(out, dict):
116
- text = out.get("generated_text", "")
117
- elif isinstance(out, list) and out and isinstance(out[0], dict):
118
- text = out[0].get("generated_text", "")
119
- else:
120
- text = out if isinstance(out, str) else ""
121
- return (text or "").strip(), None
122
- except Exception as e:
123
- return "", f"http error: {e}"
124
 
125
- def run_ocr(img: Image.Image) -> dict:
126
- """
127
- Single OCR pass + simple normalization => an item name.
128
- """
129
- errors = []
130
- pre = preprocess_for_label(img)
131
-
132
- text, err = _ocr_via_client(pre)
133
- if err or not text:
134
- errors.append(err or "empty")
135
- text, err2 = _ocr_via_http(pre)
136
- if err2:
137
- errors.append(err2)
138
-
139
- name = normalize_from_text(text)
140
- return {
141
- "name": name if name else "Unknown",
142
- "ocr_text": text,
143
- "model": OCR_MODEL,
144
- "errors": [e for e in errors if e],
145
- }
146
 
147
- # ------------------------ Normalizer (simple) ------------------------
148
- # Extend anytime with categories you care about
149
- TYPE_WORDS = [
150
- "soup", "beans", "rice", "pasta", "sauce", "salsa", "cereal", "oats",
151
- "toothpaste", "toothbrush", "soap", "shampoo", "conditioner",
152
- "lotion", "body lotion", "mayonnaise", "ketchup", "mustard",
153
- "peanut butter", "jam", "jelly", "soda", "juice", "tea", "coffee",
154
- "tuna", "chicken", "beef", "noodles", "flour", "sugar", "salt",
155
- "deodorant", "antiperspirant", "detergent", "sanitizer", "oil"
156
- ]
157
-
158
- def normalize_from_text(text: str) -> str:
159
- """
160
- Very lightweight: try to pull a 'brand' (first capitalized word)
161
- and a 'type' (any known type in the text). Fallback to the first 4-5 words.
162
- """
163
- t = (text or "").strip()
164
- if not t:
165
- return ""
166
-
167
- # candidate brand: first word that looks like Title Case or ALLCAPS
168
- tokens = [w.strip(",.:;!|/\\-()[]{}") for w in t.split()]
169
- brand = ""
170
- for w in tokens[:8]: # look near the front
171
- if len(w) >= 2 and (w.isupper() or (w[0].isupper() and w[1:].islower())):
172
- brand = w
173
- break
174
-
175
- # candidate type: first match from TYPE_WORDS
176
- low = t.lower()
177
- found_type = ""
178
- for k in TYPE_WORDS:
179
- if k in low:
180
- found_type = k
181
- break
182
-
183
- parts = [p for p in [brand, found_type] if p]
184
- if parts:
185
- return " ".join(parts).strip()
186
-
187
- # Fallback: compress to a few words
188
- return " ".join(tokens[:5]).strip()
189
-
190
- # ------------------------ Volunteer login ------------------------
191
- st.subheader("👤 Volunteer")
192
-
193
- with st.form("vol_form", clear_on_submit=True):
194
- username = st.text_input("Username")
195
- full_name = st.text_input("Full name")
196
- submitted = st.form_submit_button("Add / Continue")
197
- if submitted:
198
- if not (username and full_name):
199
- st.error("Please fill both fields.")
200
- else:
201
- try:
202
- existing = sb.table("volunteers").select("full_name").execute().data or []
203
- names = {v["full_name"].strip().lower() for v in existing}
204
- if full_name.strip().lower() not in names:
205
- sb.table("volunteers").insert({"username": username, "full_name": full_name}).execute()
206
- st.session_state["volunteer"] = username
207
- st.session_state["volunteer_name"] = full_name
208
- st.success(f"Welcome, {full_name}!")
209
- except Exception as e:
210
- st.error(f"Volunteer add/check failed: {e}")
211
 
212
- if "volunteer" not in st.session_state:
213
- st.info("Add yourself above to start logging.")
214
- st.stop()
215
 
216
- # ------------------------ Capture / Upload ------------------------
217
- st.subheader("📸 Scan label to auto-fill item")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
- c1, c2 = st.columns(2)
220
  with c1:
221
- cam = st.camera_input("Use your phone or webcam")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  with c2:
223
- up = st.file_uploader("…or upload an image", type=["png", "jpg", "jpeg"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
  img_file = cam or up
226
  if img_file:
227
  img = Image.open(img_file).convert("RGB")
228
  st.image(img, use_container_width=True)
229
-
230
- if st.button("🔍 Suggest name"):
231
- t0 = time.time()
232
- result = run_ocr(img)
233
- st.success(f"🧠 Suggested: **{result['name']}** · ⏱️ {time.time()-t0:.2f}s")
234
- with st.expander("🔎 Debug (OCR)"):
235
- st.json(result)
236
- st.session_state["scanned_item_name"] = result["name"]
237
-
238
- # ------------------------ Add inventory item ------------------------
239
- st.subheader("📥 Add inventory item")
240
-
241
- item_name = st.text_input("Item name", value=st.session_state.get("scanned_item_name", ""))
242
- quantity = st.number_input("Quantity", min_value=1, step=1, value=1)
243
- category = st.text_input("Category (optional)")
244
- expiry = st.date_input("Expiry date (optional)")
245
-
246
- if st.button("✅ Log item"):
247
- if not item_name.strip():
248
- st.warning("Enter an item name.")
249
- else:
250
  try:
251
- # Preferred simple table
252
- sb.table("inventory").insert({
253
- "item_name": item_name.strip(),
254
- "quantity": int(quantity),
255
- "category": (category.strip() or None),
256
- "expiry_date": str(expiry) if expiry else None,
257
- "added_by": st.session_state.get("volunteer", "Unknown"),
258
- }).execute()
259
- st.success("Logged to 'inventory'!")
260
- except Exception as e1:
261
- # Fallback to your existing wide log table
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  try:
263
- payload_vi = {
264
- "item_name": item_name.strip(),
265
- "qty": int(quantity),
266
- "category": (category.strip() or None),
267
- "volunteer": st.session_state.get("volunteer_name")
268
- or st.session_state.get("volunteer")
269
- or "Unknown",
270
- }
271
- sb.table("visit_items").insert(payload_vi).execute()
272
- st.success("Logged to 'visit_items'!")
273
- except Exception as e2:
274
- st.error(f"Insert failed: {e1}\nFallback failed: {e2}")
275
-
276
- # ------------------------ Live inventory (tries multiple tables) ------------------------
277
- st.subheader("📊 Live inventory")
278
-
279
- def _try_fetch(table: str):
 
 
 
 
 
 
 
 
 
 
 
 
280
  try:
281
- # try common timestamp fields first
282
- return sb.table(table).select("*").order("created_ts", desc=True).execute().data
283
  except Exception:
284
  try:
285
- return sb.table(table).select("*").order("created_at", desc=True).execute().data
 
286
  except Exception:
287
- try:
288
- return sb.table(table).select("*").limit(1000).execute().data
289
- except Exception:
290
- return None
291
-
292
- data = _try_fetch("inventory")
293
- if not data:
294
- data = _try_fetch("visit_items")
295
- if not data:
296
- data = _try_fetch("inventory_master")
297
-
298
- if data:
299
- df = pd.DataFrame(data)
300
- st.dataframe(df, use_container_width=True)
301
- st.download_button(
302
- "⬇️ Export CSV",
303
- df.to_csv(index=False).encode("utf-8"),
304
- "care_count_inventory.csv",
305
- "text/csv",
306
- )
307
- else:
308
- st.caption("No items yet or tables not found. (Tried: inventory, visit_items, inventory_master)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Care Count (Volunteers Visits Items) ---
2
+ # Delightful Laurier-themed UX + enterprise data hygiene
3
+ # - OTP email login (Supabase)
4
+ # - Shift tracking & idle/8pm auto sign-out
5
+ # - Human-friendly visit_code (with DB trigger or safe fallback)
6
+ # - VLM-assisted item name
7
+ # - RPC ingest with validation/quarantine (fallback to direct insert)
8
+ # - Volunteer Impact card (today + lifetime)
9
+
10
+ from __future__ import annotations
11
+
12
+ import os, io, time, base64, re, uuid, json
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional
15
+
16
+ import pytz
17
  import requests
18
  import pandas as pd
19
  import streamlit as st
20
  from PIL import Image, ImageOps, ImageEnhance
21
  from supabase import create_client, Client
22
 
23
+ # ------------------------ App config ------------------------
24
+ os.environ.setdefault("HOME", "/tmp")
25
+ os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
 
 
 
 
 
 
26
 
27
+ TZ = os.getenv("APP_TZ", "America/Toronto")
28
+ CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
29
+ INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
30
+
31
+ def local_now() -> datetime:
32
+ return datetime.now(pytz.timezone(TZ))
33
+
34
+ st.set_page_config(page_title="Care Count", layout="centered")
35
+ st.markdown("""
36
+ <style>
37
+ /* Laurier theme vibes */
38
+ :root { --cc-purple:#6d28d9; --cc-gold:#fde047; --cc-bg:#0b1420; --cc-panel:#0f1a2a; --cc-border:#1d2a44; }
39
+ .block-container { padding-top: 2rem; }
40
+ h1, h2, .stMarkdown h1, .stMarkdown h2 { letter-spacing:.2px }
41
+ .cc-pill { display:inline-block; padding:4px 10px; border-radius:999px; background:var(--cc-gold); color:#111827; font-weight:700; font-size:12px; }
42
+ .cc-hint { background:#10233b; border:1px solid #1f3b5b; color:#e6e8f0; padding:12px 16px; border-radius:10px; }
43
+ .cc-hero { background:#0f1a2a; border:1px solid #1f2a44; padding:16px 18px; border-radius:14px; }
44
+ .cc-btn-primary button { background:var(--cc-purple)!important; color:#fff!important; border:0!important; }
45
+ .cc-danger button { background:#7f1d1d!important; color:#fff!important; }
46
+ .status-card { display:flex; gap:16px; flex-wrap:wrap; }
47
+ .card { background:#0f1a2a; border:1px solid #1f2a44; border-radius:14px; padding:14px 16px; min-width:200px; }
48
+ .card h4 { margin:0 0 6px 0; font-size:0.95rem; color:#cbd5e1 }
49
+ .card .big { font-size:1.6rem; font-weight:700; color:#e5e7eb }
50
+ .small { color:#9aa3b2; font-size:12px }
51
+ </style>
52
+ """, unsafe_allow_html=True)
53
+
54
+ st.title("💜💛 Care Count")
55
+ st.caption("Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time.")
56
+
57
+ # ------------------------ Secrets & client ------------------------
58
+ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
59
+ v = os.getenv(name)
60
+ if v: return v
61
+ try: return st.secrets.get(name, default)
62
+ except Exception: return default
63
 
64
  SUPABASE_URL = get_secret("SUPABASE_URL")
65
+ SUPABASE_KEY = get_secret("SUPABASE_KEY") # anon key (RLS should be ON)
66
  if not SUPABASE_URL or not SUPABASE_KEY:
67
+ st.error("Missing Supabase creds. Add SUPABASE_URL & SUPABASE_KEY.")
68
  st.stop()
69
 
70
  sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
71
 
72
+ # VLM provider
73
+ PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
74
+ GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
75
+ NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
76
+ NEBIUS_BASE_URL= get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
77
+ FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
78
+ FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
79
 
80
+ st.caption(f"Provider: `{PROVIDER}` · Model: `{GEMMA_MODEL}` · TZ: `{TZ}`")
 
 
 
 
81
 
82
+ # ------------------------ Light events/audit (non-blocking) ------------------------
83
+ def log_event(action: str, actor: Optional[str], details: dict):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  try:
85
+ sb.table("events").insert({"actor_email": actor, "action": action, "details": details}).execute()
86
  except Exception:
87
+ pass
88
+
89
+ # ------------------------ Auth (Email OTP) ------------------------
90
+ def auth_block() -> tuple[bool, Optional[str]]:
91
+ if "auth_email" not in st.session_state:
92
+ st.session_state["auth_email"] = None
93
+ if "user_email" in st.session_state:
94
+ return True, st.session_state["user_email"]
95
+
96
+ st.subheader("Sign in")
97
+ with st.form("otp_request", clear_on_submit=False, border=False):
98
+ email = st.text_input("Email", value=st.session_state.get("auth_email") or "", autocomplete="email")
99
+ send = st.form_submit_button("Send login code")
100
+ if send:
101
+ if not email or "@" not in email:
102
+ st.error("Please enter a valid email.")
103
+ else:
104
+ try:
105
+ sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
106
+ st.session_state["auth_email"] = email
107
+ st.success("We emailed you a one-time code. Enter it to continue.")
108
+ except Exception as e:
109
+ st.error(f"Could not send code: {e}")
110
+
111
+ if st.session_state.get("auth_email"):
112
+ with st.form("otp_verify", clear_on_submit=True, border=False):
113
+ code = st.text_input("Enter 6-digit code", max_chars=6)
114
+ ok = st.form_submit_button("Verify & start shift")
115
+ if ok:
116
+ try:
117
+ res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
118
+ if res and res.user:
119
+ email = st.session_state["auth_email"]
120
+ # Idempotent upsert for volunteer & start shift
121
+ sb.table("volunteers").upsert({
122
+ "email": email,
123
+ "last_login_at": datetime.utcnow().isoformat(),
124
+ "shift_started_at": datetime.utcnow().isoformat(),
125
+ "shift_ended_at": None
126
+ }, on_conflict="email").execute()
127
+ st.session_state["user_email"] = email
128
+ st.session_state["shift_started"] = True
129
+ st.session_state["last_activity_at"] = local_now()
130
+ log_event("login", email, {"method":"otp"})
131
+ st.toast("Welcome back! Shift started. 💜", icon="✅")
132
+ return True, email
133
+ except Exception as e:
134
+ st.error(f"Verification failed: {e}")
135
+ return False, None
136
+
137
+ def end_shift(email: str, reason: str):
138
  try:
139
+ sb.table("volunteers").update({"shift_ended_at": datetime.utcnow().isoformat()}).eq("email", email).execute()
140
+ log_event("shift_end", email, {"reason": reason})
141
+ except Exception:
142
+ pass
143
+
144
+ def guard_cutoff_and_idle(email: str):
145
+ now = local_now()
146
+ last = st.session_state.get("last_activity_at")
147
+ if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
148
+ end_shift(email, "inactivity")
149
+ st.session_state.clear()
150
+ st.info("You were logged out due to inactivity. Thank you for volunteering today!")
151
+ st.stop()
152
+ st.session_state["last_activity_at"] = now
153
+
154
+ cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
155
+ if now >= cutoff:
156
+ end_shift(email, "cutoff_8pm")
157
+ st.session_state.clear()
158
+ st.info("We close the day at 8pm. Your shift has been ended. Thank you so much!")
159
+ st.stop()
160
+
161
+ signed_in, user_email = auth_block()
162
+ if not signed_in:
163
+ st.stop()
164
+ guard_cutoff_and_idle(user_email)
165
+
166
+ # Welcome banner (visceral, kind)
167
+ st.markdown(
168
+ f"""<div class="cc-hero">
169
+ <div class="small">Welcome,</div>
170
+ <div style="font-weight:800;font-size:1.15rem"> {user_email}</div>
171
+ <div class="small">Thank you for showing up for the community today. 💜💛</div>
172
+ </div>""",
173
+ unsafe_allow_html=True
174
+ )
175
+
176
+ # Sign-out button (prominent)
177
+ c_signout = st.columns([1,1,6])[1]
178
+ with c_signout:
179
+ st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
180
+ if st.button("🔒 Sign out"):
181
+ end_shift(user_email, "manual")
182
+ st.session_state.clear()
183
+ st.success("Signed out. See you next time!")
184
+ st.stop()
185
+ st.markdown('</div>', unsafe_allow_html=True)
186
+
187
+ # ------------------------ Volunteer Impact card ------------------------
188
+ def fetch_volunteer_row(email: str) -> dict | None:
189
+ try:
190
+ return sb.table("volunteers").select("*").eq("email", email).single().execute().data
191
+ except Exception:
192
+ return None
193
 
194
+ def items_today(email: str) -> int:
 
 
 
 
195
  try:
196
+ day = local_now().strftime("%Y-%m-%d")
197
+ # try partitioned table first
198
+ data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
199
+ .gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
200
+ .eq("volunteer", email).execute().data
201
+ return len(data or [])
202
+ except Exception:
203
+ try:
204
+ data = sb.table("visit_items").select("id,timestamp,volunteer") \
205
+ .gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
206
+ .eq("volunteer", email).execute().data
207
+ return len(data or [])
208
+ except Exception:
209
+ return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
+ vrow = fetch_volunteer_row(user_email) or {}
212
+ shift_started_at = vrow.get("shift_started_at")
213
+ lifetime_hours = vrow.get("total_hours") # may not exist yet; handled below
214
+ mins_active = 0
215
+ try:
216
+ if shift_started_at:
217
+ t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
218
+ mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
219
+ except Exception:
220
+ pass
 
 
 
 
 
 
 
 
 
 
 
221
 
222
+ st.markdown('<div class="status-card">', unsafe_allow_html=True)
223
+ st.markdown(f'<div class="card"><h4>Shift active</h4><div class="big">{mins_active} min</div><div class="small">since you signed in</div></div>', unsafe_allow_html=True)
224
+ st.markdown(f'<div class="card"><h4>Items you logged today</h4><div class="big">{items_today(user_email)}</div></div>', unsafe_allow_html=True)
225
+ if isinstance(lifetime_hours, (int, float)):
226
+ st.markdown(f'<div class="card"><h4>Lifetime hours</h4><div class="big">{round(float(lifetime_hours),1)}</div></div>', unsafe_allow_html=True)
227
+ st.markdown('</div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
+ # ------------------------ Image helpers ------------------------
230
+ def _to_png_bytes(img: Image.Image) -> bytes:
231
+ b = io.BytesIO(); img.save(b, format="PNG"); return b.getvalue()
232
 
233
+ def preprocess_for_label(img: Image.Image) -> Image.Image:
234
+ img = img.convert("RGB")
235
+ w, h = img.size
236
+ scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
237
+ if scale < 1.0: img = img.resize((int(w * scale), int(h * scale)))
238
+ img = ImageOps.autocontrast(img, cutoff=2)
239
+ img = ImageEnhance.Brightness(img).enhance(1.06)
240
+ img = ImageEnhance.Contrast(img).enhance(1.05)
241
+ img = ImageEnhance.Sharpness(img).enhance(1.15)
242
+ return img
243
+
244
+ # ------------------------ VLM client ------------------------
245
+ SYSTEM_HINT = "You label item being held in the image for a food bank. Return ONLY the item name."
246
+
247
+ def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
248
+ b64 = base64.b64encode(img_bytes).decode("utf-8")
249
+ url = f"{base_url.rstrip('/')}/chat/completions"
250
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
251
+ payload = {
252
+ "model": model_id,
253
+ "temperature": 0,
254
+ "messages": [
255
+ {"role": "system", "content": SYSTEM_HINT},
256
+ {"role": "user", "content": [
257
+ {"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
258
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
259
+ ]}
260
+ ]
261
+ }
262
+ r = requests.post(url, json=payload, headers=headers, timeout=90)
263
+ if r.status_code != 200:
264
+ raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
265
+ data = r.json()
266
+ return (data["choices"][0]["message"]["content"] or "").strip()
267
+
268
+ def gemma_item_name(img_bytes: bytes) -> str:
269
+ if PROVIDER == "nebius":
270
+ if not NEBIUS_API_KEY: raise RuntimeError("NEBIUS_API_KEY missing")
271
+ return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
272
+ elif PROVIDER == "featherless":
273
+ if not FEATH_API_KEY: raise RuntimeError("FEATHERLESS_API_KEY missing")
274
+ return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
275
+ else:
276
+ raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
277
+
278
+ # ------------------------ Normalization ------------------------
279
+ BRANDS = {
280
+ "whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
281
+ "nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
282
+ "ice river","green bottle","great value","wheat thins","vegetable thins","raid"
283
+ }
284
+ GENERIC_TYPES = {
285
+ "water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
286
+ "lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
287
+ "peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
288
+ "crackers","cookies","soup","insect killer","spray"
289
+ }
290
+ def normalize_item_name(s: str) -> str:
291
+ s = (s or "").strip()
292
+ if not s: return ""
293
+ low = re.sub(r"[®™]", "", s.lower())
294
+ for b in BRANDS: low = low.replace(b, "")
295
+ chosen = None
296
+ for t in GENERIC_TYPES:
297
+ if t in low: chosen = t; break
298
+ cleaned = " ".join(low.split())
299
+ return (chosen or cleaned.title())[:120]
300
+
301
+ def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
302
+ if not v: return None
303
+ v = re.sub(r"\s+", " ", v).strip()
304
+ return v[:maxlen] if v else None
305
+
306
+ # ------------------------ Visit flow ------------------------
307
+ st.subheader("🪪 Anonymous Student Visit")
308
+
309
+ active_visit = st.session_state.get("active_visit")
310
+
311
+ def fallback_visit_code() -> str:
312
+ """Readable visit_code if DB trigger isn't present."""
313
+ try:
314
+ day = local_now().strftime("%Y-%m-%d")
315
+ todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
316
+ .lte("started_at", f"{day} 23:59:59").execute().data or []
317
+ seq = len(todays) + 1
318
+ except Exception:
319
+ seq = int(time.time()) % 1000
320
+ return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
321
 
322
+ c1, c2, c3 = st.columns(3)
323
  with c1:
324
+ st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
325
+ if not active_visit and st.button("Start Visit"):
326
+ try:
327
+ payload = {
328
+ "visit_code": fallback_visit_code(), # DB trigger will override if present
329
+ "started_at": datetime.utcnow().isoformat(),
330
+ "ended_at": None,
331
+ "created_by": user_email
332
+ }
333
+ v = sb.table("visits").insert(payload).execute().data[0]
334
+ # If trigger generated code, keep that
335
+ if not v.get("visit_code"):
336
+ v["visit_code"] = payload["visit_code"]
337
+ st.session_state["active_visit"] = v
338
+ st.success(f"Visit #{v['id']} started · code: **{v['visit_code']}**")
339
+ log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
340
+ except Exception as e:
341
+ st.error(f"Could not start visit: {e}")
342
+ st.markdown('</div>', unsafe_allow_html=True)
343
+
344
  with c2:
345
+ if active_visit and st.button("End Visit (Checkout)"):
346
+ try:
347
+ sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
348
+ .eq("id", active_visit["id"]).execute()
349
+ st.success("Visit checked out. Ready for the next student.")
350
+ log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
351
+ st.session_state.pop("active_visit", None)
352
+ except Exception as e:
353
+ st.error(f"Could not end visit: {e}")
354
+
355
+ with c3:
356
+ if st.session_state.get("active_visit"):
357
+ v = st.session_state["active_visit"]
358
+ st.caption(f"Active visit_id: {v['id']} · code: {v.get('visit_code','')}")
359
+ else:
360
+ st.caption("No active visit.")
361
+
362
+ # ------------------------ Identify item ------------------------
363
+ st.subheader("📸 Identify item from image")
364
+
365
+ c4, c5 = st.columns(2)
366
+ with c4: cam = st.camera_input("Use your phone or webcam")
367
+ with c5: up = st.file_uploader("…or upload an image", type=["png","jpg","jpeg"])
368
 
369
  img_file = cam or up
370
  if img_file:
371
  img = Image.open(img_file).convert("RGB")
372
  st.image(img, use_container_width=True)
373
+ if st.button("🔍 Ask model for item name"):
374
+ t0, raw = time.time(), ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  try:
376
+ pre = preprocess_for_label(img); raw = gemma_item_name(_to_png_bytes(pre))
377
+ except Exception as e:
378
+ st.error(f"Provider error: {e}"); log_event("vlm_error", user_email, {"error": str(e)})
379
+ norm = normalize_item_name(raw)
380
+ if raw: st.success(f"🧠 Model: **{raw}**")
381
+ st.info(f"✨ Normalized: **{norm or '(unknown)'}** · ⏱️ {time.time()-t0:.2f}s")
382
+ st.session_state["scanned_item_name"] = norm or raw or ""
383
+ st.session_state["last_activity_at"] = local_now()
384
+
385
+ # ------------------------ Log item ------------------------
386
+ st.subheader("📬 Log item to current visit")
387
+
388
+ item_name = st.text_input("Item name", value=st.session_state.get("scanned_item_name",""))
389
+ quantity = st.number_input("Quantity", min_value=1, max_value=9999, step=1, value=1)
390
+ category = st.text_input("Category (optional)")
391
+ unit = st.text_input("Unit (optional, e.g., 500 mL, 1 L, 250 g)")
392
+ barcode = st.text_input("Barcode (optional)")
393
+
394
+ def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
395
+ key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
396
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
397
+
398
+ def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
399
+ category: Optional[str], unit: Optional[str],
400
+ barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
401
+ """
402
+ Call the RPC safe_ingest_visit_item if it exists.
403
+ Returns (ok, msg). If function is missing, raises to trigger fallback.
404
+ """
405
+ try:
406
+ res = sb.rpc("safe_ingest_visit_item", {
407
+ "p_email": email,
408
+ "p_visit_id": v_id,
409
+ "p_item_name": name,
410
+ "p_qty": qty,
411
+ "p_category": category,
412
+ "p_unit": unit,
413
+ "p_barcode": barcode,
414
+ "p_ts": ts_iso,
415
+ "p_ingest_id": ingest_id
416
+ }).execute()
417
+ # RPC returns a setof (ok boolean, msg text) — normalize
418
+ rows = res.data if isinstance(res.data, list) else []
419
+ if rows:
420
+ r0 = rows[0]
421
+ ok = bool(r0.get("ok", False))
422
+ msg = str(r0.get("msg", ""))
423
+ return ok, msg or ("ok" if ok else "failed")
424
+ # Some PostgREST setups return {} — treat as ok
425
+ return True, "ok"
426
+ except Exception as e:
427
+ # If function missing (42883) or not exposed, re-raise to use fallback
428
+ raise e
429
+
430
+ def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
431
+ category: Optional[str], unit: Optional[str],
432
+ barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
433
+ """Write into visit_items_p if present, else visit_items (keeps app working)."""
434
+ payload = {
435
+ "visit_id": v_id,
436
+ "timestamp": ts_iso,
437
+ "volunteer": email,
438
+ "item_name": name,
439
+ "category": category,
440
+ "unit": unit,
441
+ "qty": qty,
442
+ "barcode": barcode,
443
+ "weather_type": None,
444
+ "temp_c": None,
445
+ "ingest_id": ingest_id
446
+ }
447
+ try:
448
+ sb.table("visit_items_p").insert(payload).execute()
449
+ except Exception:
450
+ # legacy table fallback
451
+ payload.pop("ingest_id", None)
452
+ sb.table("visit_items").insert(payload).execute()
453
+
454
+ st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
455
+ save_disabled = not st.session_state.get("active_visit")
456
+ if st.button("✅ Save Item to Visit", disabled=save_disabled):
457
+ v = st.session_state.get("active_visit")
458
+ if not v:
459
+ st.warning("Start a visit first, then save items.")
460
+ else:
461
+ name_clean = clean_text(item_name, 120)
462
+ if not name_clean:
463
+ st.warning("Item name is required.")
464
+ else:
465
+ ts_iso = datetime.utcnow().isoformat()
466
+ ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
467
  try:
468
+ ok, msg = try_rpc_ingest(
469
+ email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
470
+ category=clean_text(category, 80), unit=clean_text(unit, 40),
471
+ barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
472
+ )
473
+ if ok:
474
+ st.success("Item logged ✅")
475
+ else:
476
+ st.warning(f"Ingest said: {msg}. (Will try direct insert.)")
477
+ fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
478
+ clean_text(category,80), clean_text(unit,40),
479
+ clean_text(barcode,64), ts_iso, ingest_id)
480
+ st.success("Item logged ✅ (fallback)")
481
+ except Exception as e:
482
+ # RPC missing or failed → direct insert
483
+ try:
484
+ fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
485
+ clean_text(category,80), clean_text(unit,40),
486
+ clean_text(barcode,64), ts_iso, ingest_id)
487
+ st.success("Item logged ✅ (fallback)")
488
+ except Exception as e2:
489
+ st.error(f"Ingest failed: {e2}")
490
+ st.session_state["last_activity_at"] = local_now()
491
+ st.session_state["scanned_item_name"] = name_clean
492
+ st.markdown('</div>', unsafe_allow_html=True)
493
+
494
+ # ------------------------ Visit items view + delete ------------------------
495
+ def load_items_for_visit(visit_id: int) -> list[dict]:
496
+ # Prefer partitioned table
497
  try:
498
+ return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
499
+ .order("timestamp", desc=True).limit(500).execute().data or []
500
  except Exception:
501
  try:
502
+ return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
503
+ .order("timestamp", desc=True).limit(500).execute().data or []
504
  except Exception:
505
+ return []
506
+
507
+ def delete_item(table: str, item_id: int):
508
+ try:
509
+ sb.table(table).delete().eq("id", item_id).execute()
510
+ except Exception as e:
511
+ raise e
512
+
513
+ if st.session_state.get("active_visit"):
514
+ st.subheader("🧾 Items in this visit")
515
+ rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
516
+ if rows:
517
+ df = pd.DataFrame(rows)
518
+ st.dataframe(df, use_container_width=True)
519
+ with st.expander("🗑️ Delete an item (if mis-logged)"):
520
+ ids = [r["id"] for r in rows if "id" in r]
521
+ choice = st.selectbox("Choose item id", ids) if ids else None
522
+ st.markdown('<div class="cc-danger">', unsafe_allow_html=True)
523
+ if st.button("Delete selected", disabled=not bool(ids)):
524
+ if choice is None:
525
+ st.warning("Pick an id.")
526
+ else:
527
+ # try both tables safely
528
+ try:
529
+ delete_item("visit_items_p", int(choice))
530
+ except Exception:
531
+ delete_item("visit_items", int(choice))
532
+ st.success("Deleted.")
533
+ st.markdown('</div>', unsafe_allow_html=True)
534
+ else:
535
+ st.caption("No items logged for this visit yet.")
536
+
537
+ # ------------------------ Analytics (light) ------------------------
538
+ st.subheader("📈 Today")
539
+ try:
540
+ daily = sb.table("v_daily_activity").select("*").execute().data
541
+ today_str = local_now().strftime("%Y-%m-%d")
542
+ today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
543
+ visits = int(today["visits"]) if today and "visits" in today else 0
544
+ items = int(today["items"]) if today and "items" in today else 0
545
+ st.markdown(f"**Visits:** {visits} · **Items:** {items}")
546
+ except Exception:
547
+ st.caption("Analytics view unavailable yet.")
548
+
549
+ # ------------------------ Gentle reminder ------------------------
550
+ st.markdown(
551
+ """<div class="cc-hint">
552
+ 💡 When you’re done, please <b>End Visit</b> and <b>Sign out</b>.<br>
553
+ We’ll auto-sign you out after inactivity or at 8pm local time. 💜
554
+ </div>""",
555
+ unsafe_allow_html=True
556
+ )