feng-x commited on
Commit
3ae7fa7
·
verified ·
1 Parent(s): 5955159

Upload folder using huggingface_hub

Browse files
AGENTS.md CHANGED
@@ -192,6 +192,7 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
192
  | `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
193
  | `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
194
  | `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
 
195
 
196
  `/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
197
 
@@ -216,6 +217,68 @@ alter table public.measurements
216
  add column if not exists feedback_message text;
217
  ```
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  ---
220
 
221
  ## Important Technical Details
 
192
  | `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
193
  | `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
194
  | `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
195
+ | `/feedback` | `templates/feedback.html` | v8 public post-shipment fit-feedback form (all UAs, no redirect). |
196
 
197
  `/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
198
 
 
217
  add column if not exists feedback_message text;
218
  ```
219
 
220
+ ### KOL identity field: email (the cross-table join key)
221
+
222
+ Both web surfaces collect a single required identity field, now labelled
223
+ **Email** (`type="email"`, `autocomplete="email"`, `inputmode="email"`,
224
+ posted as `kol_email`). The server normalizes it **trim + lowercase**.
225
+ Email — not name — is the key used to join `measurements` ↔ the v8
226
+ `feedback` table ↔ the ops shipping-records CSV (names mis-merge and the
227
+ shipping source is email-native). See `doc/v8/PRD.md`.
228
+
229
+ Two columns on `measurements`:
230
+
231
+ | Column | Notes |
232
+ |---|---|
233
+ | `kol_email` | `text`, normalized address; the join key for new rows. |
234
+ | `kol_name` | `text`, **legacy only** — pre-email historical rows; NULL on new rows. |
235
+
236
+ ```sql
237
+ alter table public.measurements add column if not exists kol_email text;
238
+ ```
239
+
240
+ Stored object paths slug the **email local-part only** (`_email_local_part`
241
+ in `web_demo/app.py`), never the full address, to keep PII out of bucket
242
+ paths. Admin grouping/display and CSV export prefer `kol_email` and fall
243
+ back to `kol_name` for legacy rows.
244
+
245
+ ### v8 post-shipment feedback (`/feedback` + `feedback` table)
246
+
247
+ A **separate** flow from the post-result rating above — different
248
+ lifecycle (5–7 days after the ring ships, not right after measurement)
249
+ and a different table. Don't confuse the two:
250
+
251
+ | | Post-result rating | v8 fit feedback |
252
+ |---|---|---|
253
+ | Route (POST) | `/api/feedback` | `/api/fit-feedback` |
254
+ | Storage | columns on `measurements` (patched by `run_id`) | new `feedback` table (standalone row) |
255
+ | Question | "did the website work?" | "which finger did the shipped ring fit?" |
256
+
257
+ The `feedback` table is **intentionally decoupled** — no `run_id`, no
258
+ foreign key. The only link to `measurements` is the normalized
259
+ `kol_email`, joined at analysis time in a pandas notebook (see
260
+ `doc/v8/analysis.md`). `GET /feedback` renders `templates/feedback.html`
261
+ (single-page form, reuses `mobile.css`; **not** the `/m` step framework).
262
+ `POST /api/fit-feedback` normalizes the email, uploads the optional photo
263
+ under `feedback/` (local-part slug only, same PII stance), and inserts
264
+ via `save_feedback`. The photo upload + insert are **synchronous** (no
265
+ heavy compute, unlike `/api/measure`), so the row lands complete. RLS is
266
+ enabled on the table (the app's service key bypasses it; this blocks
267
+ anon-key reads of the email PII). The admin dashboard has a **Feedback**
268
+ tab (`/api/admin/feedback` → `list_feedback`) that lists the rows
269
+ read-only; deeper analysis is still pandas-only (`doc/v8/analysis.md`).
270
+ Table columns:
271
+
272
+ ```sql
273
+ create table public.feedback (
274
+ id uuid primary key default gen_random_uuid(),
275
+ submitted_at timestamptz not null default now(),
276
+ kol_email text not null, -- normalized join key
277
+ received_size text, received_model text, best_fit_finger text,
278
+ fit_quality text, hand text, photo_url text, notes text
279
+ );
280
+ ```
281
+
282
  ---
283
 
284
  ## Important Technical Details
CLAUDE.md CHANGED
@@ -192,6 +192,7 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
192
  | `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
193
  | `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
194
  | `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
 
195
 
196
  `/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
197
 
@@ -216,6 +217,68 @@ alter table public.measurements
216
  add column if not exists feedback_message text;
217
  ```
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  ---
220
 
221
  ## Important Technical Details
 
192
  | `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
193
  | `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
194
  | `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
195
+ | `/feedback` | `templates/feedback.html` | v8 public post-shipment fit-feedback form (all UAs, no redirect). |
196
 
197
  `/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
198
 
 
217
  add column if not exists feedback_message text;
218
  ```
219
 
220
+ ### KOL identity field: email (the cross-table join key)
221
+
222
+ Both web surfaces collect a single required identity field, now labelled
223
+ **Email** (`type="email"`, `autocomplete="email"`, `inputmode="email"`,
224
+ posted as `kol_email`). The server normalizes it **trim + lowercase**.
225
+ Email — not name — is the key used to join `measurements` ↔ the v8
226
+ `feedback` table ↔ the ops shipping-records CSV (names mis-merge and the
227
+ shipping source is email-native). See `doc/v8/PRD.md`.
228
+
229
+ Two columns on `measurements`:
230
+
231
+ | Column | Notes |
232
+ |---|---|
233
+ | `kol_email` | `text`, normalized address; the join key for new rows. |
234
+ | `kol_name` | `text`, **legacy only** — pre-email historical rows; NULL on new rows. |
235
+
236
+ ```sql
237
+ alter table public.measurements add column if not exists kol_email text;
238
+ ```
239
+
240
+ Stored object paths slug the **email local-part only** (`_email_local_part`
241
+ in `web_demo/app.py`), never the full address, to keep PII out of bucket
242
+ paths. Admin grouping/display and CSV export prefer `kol_email` and fall
243
+ back to `kol_name` for legacy rows.
244
+
245
+ ### v8 post-shipment feedback (`/feedback` + `feedback` table)
246
+
247
+ A **separate** flow from the post-result rating above — different
248
+ lifecycle (5–7 days after the ring ships, not right after measurement)
249
+ and a different table. Don't confuse the two:
250
+
251
+ | | Post-result rating | v8 fit feedback |
252
+ |---|---|---|
253
+ | Route (POST) | `/api/feedback` | `/api/fit-feedback` |
254
+ | Storage | columns on `measurements` (patched by `run_id`) | new `feedback` table (standalone row) |
255
+ | Question | "did the website work?" | "which finger did the shipped ring fit?" |
256
+
257
+ The `feedback` table is **intentionally decoupled** — no `run_id`, no
258
+ foreign key. The only link to `measurements` is the normalized
259
+ `kol_email`, joined at analysis time in a pandas notebook (see
260
+ `doc/v8/analysis.md`). `GET /feedback` renders `templates/feedback.html`
261
+ (single-page form, reuses `mobile.css`; **not** the `/m` step framework).
262
+ `POST /api/fit-feedback` normalizes the email, uploads the optional photo
263
+ under `feedback/` (local-part slug only, same PII stance), and inserts
264
+ via `save_feedback`. The photo upload + insert are **synchronous** (no
265
+ heavy compute, unlike `/api/measure`), so the row lands complete. RLS is
266
+ enabled on the table (the app's service key bypasses it; this blocks
267
+ anon-key reads of the email PII). The admin dashboard has a **Feedback**
268
+ tab (`/api/admin/feedback` → `list_feedback`) that lists the rows
269
+ read-only; deeper analysis is still pandas-only (`doc/v8/analysis.md`).
270
+ Table columns:
271
+
272
+ ```sql
273
+ create table public.feedback (
274
+ id uuid primary key default gen_random_uuid(),
275
+ submitted_at timestamptz not null default now(),
276
+ kol_email text not null, -- normalized join key
277
+ received_size text, received_model text, best_fit_finger text,
278
+ fit_quality text, hand text, photo_url text, notes text
279
+ );
280
+ ```
281
+
282
  ---
283
 
284
  ## Important Technical Details
web_demo/app.py CHANGED
@@ -36,11 +36,14 @@ from src.ai_recommendation import ai_explain_recommendation
36
  from web_demo.supabase_client import (
37
  upload_file,
38
  save_measurement,
 
 
39
  update_measurement_feedback,
40
  FEEDBACK_OK,
41
  FEEDBACK_NO_ROW,
42
  FEEDBACK_DISABLED,
43
  list_measurements,
 
44
  list_measurements_for_stats,
45
  update_ground_truth,
46
  delete_measurement,
@@ -115,11 +118,21 @@ def _slugify(name: str) -> str:
115
  return slug or "anon"
116
 
117
 
118
- def _make_base_name(kol_name: str) -> Tuple[str, str]:
119
- """Return (base_name, run_id). base_name = '{slug}_{timestamp}_{shortid}'."""
 
 
 
 
 
 
 
 
 
 
120
  run_id = uuid.uuid4().hex[:8]
121
  timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
122
- base_name = f"{_slugify(kol_name)}_{timestamp}_{run_id}"
123
  return base_name, run_id
124
 
125
 
@@ -184,7 +197,10 @@ def _read_form_settings() -> Dict[str, Any]:
184
  return {
185
  "finger_index": request.form.get("finger_index", "index"),
186
  "mode": request.form.get("mode", "single"),
187
- "kol_name": request.form.get("kol_name", "").strip(),
 
 
 
188
  "ring_model": ring_model,
189
  "capture_method": capture_method,
190
  "gate_telemetry": gate_telemetry,
@@ -231,6 +247,14 @@ def index_dev():
231
  return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=True)
232
 
233
 
 
 
 
 
 
 
 
 
234
  @app.route("/results/<path:filename>")
235
  def serve_result(filename: str):
236
  return send_from_directory(RESULTS_DIR, filename)
@@ -254,7 +278,7 @@ def api_measure():
254
  return jsonify({"success": False, "error": "Unsupported file type"}), 400
255
 
256
  settings = _read_form_settings()
257
- base_name, run_id = _make_base_name(settings["kol_name"])
258
  suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
259
  upload_name = f"{base_name}{suffix}"
260
  upload_path = UPLOAD_DIR / upload_name
@@ -269,7 +293,7 @@ def api_measure():
269
  image=image,
270
  input_image_url=f"/uploads/{upload_name}",
271
  ring_model=settings["ring_model"],
272
- kol_name=settings["kol_name"],
273
  capture_method=settings["capture_method"],
274
  gate_telemetry=settings["gate_telemetry"],
275
  upload_path=upload_path,
@@ -292,13 +316,13 @@ def api_measure_default():
292
  return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
293
 
294
  settings = _read_form_settings()
295
- base_name, run_id = _make_base_name(settings["kol_name"] or "sample")
296
 
297
  common_kwargs = dict(
298
  image=image,
299
  input_image_url=DEFAULT_SAMPLE_URL,
300
  ring_model=settings["ring_model"],
301
- kol_name=settings["kol_name"],
302
  capture_method=settings["capture_method"],
303
  gate_telemetry=settings["gate_telemetry"],
304
  base_name=base_name,
@@ -314,7 +338,7 @@ def _run_measurement(
314
  finger_index: str,
315
  input_image_url: str,
316
  ring_model: str = DEFAULT_RING_MODEL,
317
- kol_name: str = "",
318
  capture_method: str = DEFAULT_CAPTURE_METHOD,
319
  gate_telemetry: Optional[Dict[str, Any]] = None,
320
  upload_path: Optional[Path] = None,
@@ -374,7 +398,9 @@ def _run_measurement(
374
  result_png_name=result_png_name,
375
  record={
376
  "run_id": run_id,
377
- "kol_name": kol_name,
 
 
378
  "mode": "single",
379
  "ring_model": ring_model,
380
  "finger_index": finger_index,
@@ -395,7 +421,7 @@ def _run_multi_measurement(
395
  image,
396
  input_image_url: str,
397
  ring_model: str = DEFAULT_RING_MODEL,
398
- kol_name: str = "",
399
  capture_method: str = DEFAULT_CAPTURE_METHOD,
400
  gate_telemetry: Optional[Dict[str, Any]] = None,
401
  upload_path: Optional[Path] = None,
@@ -474,7 +500,8 @@ def _run_multi_measurement(
474
  result_png_name=result_png_name,
475
  record={
476
  "run_id": run_id,
477
- "kol_name": kol_name,
 
478
  "mode": "multi",
479
  "ring_model": ring_model,
480
  "overall_best_size": result.get("overall_best_size"),
@@ -558,6 +585,62 @@ def api_feedback():
558
  return jsonify({"success": False, "error": "Could not save feedback, please retry"}), 500
559
 
560
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  # ---------------------------------------------------------------------------
562
  # Admin routes
563
  # ---------------------------------------------------------------------------
@@ -583,6 +666,14 @@ def api_admin_measurements():
583
  return jsonify(rows)
584
 
585
 
 
 
 
 
 
 
 
 
586
  @app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
587
  def api_admin_update_gt(measurement_id: str):
588
  if not _check_admin_token():
@@ -622,12 +713,13 @@ def _parse_iso_to_utc_date(iso_str: str) -> Optional[date]:
622
  return None
623
 
624
 
625
- def _kol_key(name: Optional[str]) -> Optional[str]:
626
- """Normalize kol_name for grouping (trim + lower). Empty → None so we
627
- don't count anonymous sample runs as a distinct KOL."""
628
- if not name:
 
629
  return None
630
- s = name.strip().lower()
631
  return s or None
632
 
633
 
@@ -683,12 +775,15 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
683
 
684
  d = _parse_iso_to_utc_date(created_at)
685
 
686
- kkey = _kol_key(row.get("kol_name"))
 
 
 
687
  in_window = d is not None and window_start <= d <= today
688
  if kkey:
689
  kol_counts[kkey] += 1
690
  if kkey not in kol_display:
691
- kol_display[kkey] = (row.get("kol_name") or "").strip()
692
  if in_window:
693
  kol_counts_window[kkey] += 1
694
  if created_at and (kkey not in kol_last_seen or created_at > kol_last_seen[kkey]):
@@ -770,7 +865,9 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
770
 
771
  top_kols = [
772
  {
773
- "kol_name": kol_display.get(k, k),
 
 
774
  "count": c,
775
  "last_at": kol_last_seen.get(k),
776
  }
@@ -876,7 +973,7 @@ def api_admin_export_csv():
876
  rows = list_measurements(limit=5000)
877
  output = io.StringIO()
878
  fieldnames = [
879
- "kol_name", "created_at", "mode", "ring_model",
880
  "overall_best_size", "overall_range_min", "overall_range_max",
881
  "index_size", "index_diameter", "index_confidence",
882
  "middle_size", "middle_diameter", "middle_confidence",
@@ -890,6 +987,7 @@ def api_admin_export_csv():
890
  writer.writeheader()
891
  for row in rows:
892
  flat = {
 
893
  "kol_name": _csv_safe(row.get("kol_name", "")),
894
  "created_at": row.get("created_at", ""),
895
  "mode": row.get("mode", ""),
 
36
  from web_demo.supabase_client import (
37
  upload_file,
38
  save_measurement,
39
+ save_feedback,
40
+ persistence_enabled,
41
  update_measurement_feedback,
42
  FEEDBACK_OK,
43
  FEEDBACK_NO_ROW,
44
  FEEDBACK_DISABLED,
45
  list_measurements,
46
+ list_feedback,
47
  list_measurements_for_stats,
48
  update_ground_truth,
49
  delete_measurement,
 
118
  return slug or "anon"
119
 
120
 
121
+ def _email_local_part(email: str) -> str:
122
+ """Return the part of an email before '@'. Used only for naming the
123
+ stored object — we deliberately keep the full address out of bucket
124
+ paths (it's PII; relevant for EU KOLs)."""
125
+ return (email or "").split("@", 1)[0]
126
+
127
+
128
+ def _make_base_name(kol_email: str) -> Tuple[str, str]:
129
+ """Return (base_name, run_id). base_name = '{slug}_{timestamp}_{shortid}'.
130
+
131
+ The slug is derived from the email local-part only — never the full
132
+ address — so stored object paths don't leak the domain/full email."""
133
  run_id = uuid.uuid4().hex[:8]
134
  timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
135
+ base_name = f"{_slugify(_email_local_part(kol_email))}_{timestamp}_{run_id}"
136
  return base_name, run_id
137
 
138
 
 
197
  return {
198
  "finger_index": request.form.get("finger_index", "index"),
199
  "mode": request.form.get("mode", "single"),
200
+ # Email is the cross-table join key (see doc/v8/PRD.md). Normalize
201
+ # to trim + lowercase so the same address matches across the
202
+ # measurement, feedback, and shipping data sources.
203
+ "kol_email": request.form.get("kol_email", "").strip().lower(),
204
  "ring_model": ring_model,
205
  "capture_method": capture_method,
206
  "gate_telemetry": gate_telemetry,
 
247
  return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=True)
248
 
249
 
250
+ @app.route("/feedback")
251
+ def feedback_form():
252
+ """Public post-shipment KOL fit-feedback form (v8). Mobile-first but
253
+ served to all UAs — desktop KOLs must be able to fill it too, so no
254
+ UA redirect here (unlike `/`)."""
255
+ return render_template("feedback.html")
256
+
257
+
258
  @app.route("/results/<path:filename>")
259
  def serve_result(filename: str):
260
  return send_from_directory(RESULTS_DIR, filename)
 
278
  return jsonify({"success": False, "error": "Unsupported file type"}), 400
279
 
280
  settings = _read_form_settings()
281
+ base_name, run_id = _make_base_name(settings["kol_email"])
282
  suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
283
  upload_name = f"{base_name}{suffix}"
284
  upload_path = UPLOAD_DIR / upload_name
 
293
  image=image,
294
  input_image_url=f"/uploads/{upload_name}",
295
  ring_model=settings["ring_model"],
296
+ kol_email=settings["kol_email"],
297
  capture_method=settings["capture_method"],
298
  gate_telemetry=settings["gate_telemetry"],
299
  upload_path=upload_path,
 
316
  return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
317
 
318
  settings = _read_form_settings()
319
+ base_name, run_id = _make_base_name(settings["kol_email"] or "sample")
320
 
321
  common_kwargs = dict(
322
  image=image,
323
  input_image_url=DEFAULT_SAMPLE_URL,
324
  ring_model=settings["ring_model"],
325
+ kol_email=settings["kol_email"],
326
  capture_method=settings["capture_method"],
327
  gate_telemetry=settings["gate_telemetry"],
328
  base_name=base_name,
 
338
  finger_index: str,
339
  input_image_url: str,
340
  ring_model: str = DEFAULT_RING_MODEL,
341
+ kol_email: str = "",
342
  capture_method: str = DEFAULT_CAPTURE_METHOD,
343
  gate_telemetry: Optional[Dict[str, Any]] = None,
344
  upload_path: Optional[Path] = None,
 
398
  result_png_name=result_png_name,
399
  record={
400
  "run_id": run_id,
401
+ # kol_name is left unset for new rows — it is retained on the
402
+ # table only for pre-email historical data. Email is the key.
403
+ "kol_email": kol_email,
404
  "mode": "single",
405
  "ring_model": ring_model,
406
  "finger_index": finger_index,
 
421
  image,
422
  input_image_url: str,
423
  ring_model: str = DEFAULT_RING_MODEL,
424
+ kol_email: str = "",
425
  capture_method: str = DEFAULT_CAPTURE_METHOD,
426
  gate_telemetry: Optional[Dict[str, Any]] = None,
427
  upload_path: Optional[Path] = None,
 
500
  result_png_name=result_png_name,
501
  record={
502
  "run_id": run_id,
503
+ # kol_name left unset for new rows (see _run_measurement).
504
+ "kol_email": kol_email,
505
  "mode": "multi",
506
  "ring_model": ring_model,
507
  "overall_best_size": result.get("overall_best_size"),
 
585
  return jsonify({"success": False, "error": "Could not save feedback, please retry"}), 500
586
 
587
 
588
+ # Post-shipment fit-feedback (v8). Distinct from /api/feedback above: that
589
+ # attaches a post-measurement star rating to a measurements row; this writes
590
+ # a standalone row to the decoupled `feedback` table (no run_id, no FK). The
591
+ # only link between the two tables is the normalized kol_email. See
592
+ # doc/v8/PRD.md.
593
+ @app.route("/api/fit-feedback", methods=["POST"])
594
+ def api_fit_feedback():
595
+ # Multipart because the photo is optional; non-file fields arrive in
596
+ # request.form regardless.
597
+ kol_email = (request.form.get("kol_email") or "").strip().lower()
598
+ if not kol_email or "@" not in kol_email:
599
+ return jsonify({"success": False, "error": "A valid email is required"}), 400
600
+
601
+ def _field(name: str) -> Optional[str]:
602
+ v = (request.form.get(name) or "").strip()
603
+ return v or None
604
+
605
+ # Optional photo of the ring on the best-fit finger. Uploaded to the
606
+ # existing bucket under feedback/, slugging the email local-part only
607
+ # (never the full address) so the bucket path doesn't leak PII.
608
+ photo_url = None
609
+ file = request.files.get("photo")
610
+ if file and file.filename:
611
+ if not _allowed_file(file.filename):
612
+ return jsonify({"success": False, "error": "Unsupported photo type"}), 400
613
+ base_name, _ = _make_base_name(kol_email)
614
+ suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
615
+ photo_name = f"{base_name}{suffix}"
616
+ photo_path = UPLOAD_DIR / photo_name
617
+ file.save(str(photo_path))
618
+ # Synchronous upload (no heavy compute here, unlike /api/measure) so
619
+ # the inserted row already carries photo_url — no follow-up patch.
620
+ photo_url = upload_file(str(photo_path), f"feedback/{photo_name}")
621
+
622
+ record = {
623
+ "kol_email": kol_email,
624
+ "received_size": _field("received_size"),
625
+ "received_model": _field("received_model"),
626
+ "best_fit_finger": _field("best_fit_finger"),
627
+ "fit_quality": _field("fit_quality"),
628
+ "hand": _field("hand"),
629
+ "photo_url": photo_url,
630
+ "notes": _field("notes"),
631
+ }
632
+
633
+ row_id = save_feedback(record)
634
+ if row_id is not None:
635
+ return jsonify({"success": True})
636
+ if not persistence_enabled():
637
+ # RING_DISABLE_SUPABASE / unconfigured env (local dev) — the form did
638
+ # what it could; report success rather than a scary error.
639
+ return jsonify({"success": True})
640
+ # Persistence is on but the insert returned nothing / raised.
641
+ return jsonify({"success": False, "error": "Could not save feedback, please retry"}), 500
642
+
643
+
644
  # ---------------------------------------------------------------------------
645
  # Admin routes
646
  # ---------------------------------------------------------------------------
 
666
  return jsonify(rows)
667
 
668
 
669
+ @app.route("/api/admin/feedback")
670
+ def api_admin_feedback():
671
+ if not _check_admin_token():
672
+ return jsonify({"error": "Unauthorized"}), 401
673
+ rows = list_feedback(limit=500)
674
+ return jsonify(rows)
675
+
676
+
677
  @app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
678
  def api_admin_update_gt(measurement_id: str):
679
  if not _check_admin_token():
 
713
  return None
714
 
715
 
716
+ def _kol_key(identifier: Optional[str]) -> Optional[str]:
717
+ """Normalize a KOL identifier for grouping (trim + lower). Empty → None
718
+ so we don't count anonymous sample runs as a distinct KOL. Callers pass
719
+ the email (the join key) with kol_name as a fallback for legacy rows."""
720
+ if not identifier:
721
  return None
722
+ s = identifier.strip().lower()
723
  return s or None
724
 
725
 
 
775
 
776
  d = _parse_iso_to_utc_date(created_at)
777
 
778
+ # Prefer email (the join key); fall back to kol_name for rows that
779
+ # predate the email switch.
780
+ kol_ident = row.get("kol_email") or row.get("kol_name")
781
+ kkey = _kol_key(kol_ident)
782
  in_window = d is not None and window_start <= d <= today
783
  if kkey:
784
  kol_counts[kkey] += 1
785
  if kkey not in kol_display:
786
+ kol_display[kkey] = (kol_ident or "").strip()
787
  if in_window:
788
  kol_counts_window[kkey] += 1
789
  if created_at and (kkey not in kol_last_seen or created_at > kol_last_seen[kkey]):
 
865
 
866
  top_kols = [
867
  {
868
+ # Display label is the email (the join key) or, for legacy
869
+ # rows, the name — hence kol_ident, not kol_name.
870
+ "kol_ident": kol_display.get(k, k),
871
  "count": c,
872
  "last_at": kol_last_seen.get(k),
873
  }
 
973
  rows = list_measurements(limit=5000)
974
  output = io.StringIO()
975
  fieldnames = [
976
+ "kol_email", "kol_name", "created_at", "mode", "ring_model",
977
  "overall_best_size", "overall_range_min", "overall_range_max",
978
  "index_size", "index_diameter", "index_confidence",
979
  "middle_size", "middle_diameter", "middle_confidence",
 
987
  writer.writeheader()
988
  for row in rows:
989
  flat = {
990
+ "kol_email": _csv_safe(row.get("kol_email", "")),
991
  "kol_name": _csv_safe(row.get("kol_name", "")),
992
  "created_at": row.get("created_at", ""),
993
  "mode": row.get("mode", ""),
web_demo/static/app.js CHANGED
@@ -111,7 +111,10 @@ const RING_SIZE_TABLES = {
111
  };
112
  const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
113
 
114
- const kolNameInput = document.getElementById("kolNameInput");
 
 
 
115
 
116
  const buildMeasureSettings = () => {
117
  const fingerSelect = form.querySelector('[name="finger_index"]');
@@ -127,7 +130,7 @@ const buildMeasureSettings = () => {
127
  mode: mode,
128
  ring_model: ringModel,
129
  ai_explain: aiOn ? "1" : "0",
130
- kol_name: kolNameInput ? kolNameInput.value.trim() : "",
131
  };
132
  };
133
 
@@ -318,9 +321,9 @@ form.addEventListener("submit", async (event) => {
318
  event.preventDefault();
319
 
320
  const settings = buildMeasureSettings();
321
- if (!settings.kol_name) {
322
- setStatus("Please enter your Name / ID before measuring.", { error: true });
323
- kolNameInput.focus();
324
  return;
325
  }
326
  const formData = new FormData();
@@ -329,7 +332,7 @@ form.addEventListener("submit", async (event) => {
329
  formData.append("mode", settings.mode);
330
  formData.append("ring_model", settings.ring_model);
331
  formData.append("ai_explain", settings.ai_explain);
332
- formData.append("kol_name", settings.kol_name);
333
 
334
  const file = imageInput.files[0];
335
  if (file) {
 
111
  };
112
  const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
113
 
114
+ const kolEmailInput = document.getElementById("kolEmailInput");
115
+ // Loose RFC-ish check — `something@something.tld`. Email is the
116
+ // cross-table join key (see doc/v8/PRD.md), so validate before submit.
117
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
118
 
119
  const buildMeasureSettings = () => {
120
  const fingerSelect = form.querySelector('[name="finger_index"]');
 
130
  mode: mode,
131
  ring_model: ringModel,
132
  ai_explain: aiOn ? "1" : "0",
133
+ kol_email: kolEmailInput ? kolEmailInput.value.trim().toLowerCase() : "",
134
  };
135
  };
136
 
 
321
  event.preventDefault();
322
 
323
  const settings = buildMeasureSettings();
324
+ if (!EMAIL_RE.test(settings.kol_email)) {
325
+ setStatus("Please enter a valid email before measuring.", { error: true });
326
+ kolEmailInput.focus();
327
  return;
328
  }
329
  const formData = new FormData();
 
332
  formData.append("mode", settings.mode);
333
  formData.append("ring_model", settings.ring_model);
334
  formData.append("ai_explain", settings.ai_explain);
335
+ formData.append("kol_email", settings.kol_email);
336
 
337
  const file = imageInput.files[0];
338
  if (file) {
web_demo/static/feedback/feedback.css ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Page-specific styles for /feedback. The bulk of the look comes from
2
+ mobile.css (imported first): .step / .panel / .controls / .primary /
3
+ .form-error / hero-* are all reused so this form matches the /m flow.
4
+ This file only adds what the step CSS doesn't already cover: the two
5
+ control types unique to this form (textarea + file), the link-styled
6
+ primary button on the success screen, and the success screen itself. */
7
+
8
+ /* Slightly more breathing room between questions than the shared
9
+ .controls default (14px) — this form reads as a vertical
10
+ questionnaire, so the questions want clearer separation. */
11
+ .step-feedback .controls {
12
+ gap: 22px;
13
+ }
14
+
15
+ /* Textarea + file input inside .controls — mobile.css styles
16
+ input[type="text"|"email"] and select, but not these two. */
17
+ .controls textarea {
18
+ width: 100%;
19
+ border: 1px solid var(--border);
20
+ border-radius: 12px;
21
+ padding: 10px 14px;
22
+ /* ≥16px keeps iOS Safari from auto-zooming on focus. */
23
+ font-size: 1rem;
24
+ line-height: 1.4;
25
+ font-family: inherit;
26
+ background: white;
27
+ color: var(--ink);
28
+ resize: vertical;
29
+ min-height: 72px;
30
+ }
31
+
32
+ .controls input[type="file"] {
33
+ width: 100%;
34
+ font-size: 1rem;
35
+ color: var(--ink-soft);
36
+ }
37
+
38
+ /* Enlarge the native "Choose File" button — iOS Safari renders it small
39
+ by default. Both selectors cover Safari (-webkit-) and the standard. */
40
+ .controls input[type="file"]::-webkit-file-upload-button,
41
+ .controls input[type="file"]::file-selector-button {
42
+ font-size: 1rem;
43
+ padding: 10px 16px;
44
+ margin-right: 12px;
45
+ border: 1px solid var(--border);
46
+ border-radius: 10px;
47
+ background: white;
48
+ color: var(--accent);
49
+ cursor: pointer;
50
+ }
51
+
52
+ /* Inline per-field validation error — sits directly under the offending
53
+ control (injected by feedback.js). Bottom-of-form errors are invisible
54
+ on mobile once the field is focused and the keyboard covers them, so
55
+ the message lives next to the field instead. */
56
+ .field-error {
57
+ display: block;
58
+ margin-top: 2px;
59
+ font-size: 0.82rem;
60
+ font-weight: 600;
61
+ color: var(--accent);
62
+ }
63
+
64
+ /* Red ring on the control flagged invalid, so the field itself reads as
65
+ the error target, not just the text below it. */
66
+ .controls input[aria-invalid="true"],
67
+ .controls select[aria-invalid="true"] {
68
+ border-color: var(--accent);
69
+ }
70
+
71
+ /* --- Success screen --------------------------------------------- */
72
+
73
+ .fb-success {
74
+ display: flex;
75
+ flex-direction: column;
76
+ justify-content: center;
77
+ }
78
+
79
+ .fb-success-inner {
80
+ text-align: center;
81
+ max-width: 36ch;
82
+ margin: 0 auto;
83
+ }
84
+
85
+ .fb-success .hero-sub {
86
+ margin: 0 auto 20px;
87
+ }
88
+
89
+ .fb-success-check {
90
+ width: 64px;
91
+ height: 64px;
92
+ margin: 0 auto 20px;
93
+ border-radius: 50%;
94
+ background: #d8f1de;
95
+ color: #1f6b34;
96
+ font-size: 34px;
97
+ line-height: 64px;
98
+ font-weight: 700;
99
+ }
100
+
web_demo/static/feedback/feedback.js ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // /feedback — post-shipment KOL fit-feedback form (v8).
2
+ //
3
+ // A single self-contained screen (no step framework — that's the /m
4
+ // capture coach's machinery and irrelevant here). Validates the
5
+ // required fields, POSTs multipart to /api/fit-feedback, and swaps the
6
+ // form for a thank-you + "measure now" CTA on success.
7
+ //
8
+ // Email is the cross-table join key (see doc/v8/PRD.md), so it is the
9
+ // one field validated for format; the rest are required-but-free.
10
+ //
11
+ // Validation errors render INLINE, directly under the offending field
12
+ // (not in one spot at the bottom): on mobile we focus the bad field, so
13
+ // the keyboard would hide a bottom-of-form message entirely.
14
+
15
+ // Loose RFC-ish check — `something@something.tld`. Mirrors the desktop /
16
+ // mobile measurement forms so a KOL who uses both sees the same rule.
17
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
18
+
19
+ const form = document.getElementById("feedbackForm");
20
+ const errorEl = document.getElementById("formError");
21
+ const submitBtn = document.getElementById("submitBtn");
22
+
23
+ const el = (id) => document.getElementById(id);
24
+ const val = (id) => (el(id).value || "").trim();
25
+
26
+ // Required selects/inputs and the human label used in the error message
27
+ // when one is left blank.
28
+ const REQUIRED = [
29
+ ["receivedSize", "the ring size you received"],
30
+ ["receivedModel", "the ring model"],
31
+ ["bestFitFinger", "which finger fit best"],
32
+ ["fitQuality", "how it fit"],
33
+ ["hand", "which hand"],
34
+ ];
35
+
36
+ // Every field that can produce a validation error, in DOM order.
37
+ const FIELD_IDS = ["kolEmail", ...REQUIRED.map(([id]) => id)];
38
+
39
+ // Inject an inline error slot under each validatable field and clear it
40
+ // as soon as the user edits that field.
41
+ const errSlots = {};
42
+ for (const id of FIELD_IDS) {
43
+ const input = el(id);
44
+ const slot = document.createElement("small");
45
+ slot.className = "field-error";
46
+ slot.hidden = true;
47
+ input.closest("label").appendChild(slot);
48
+ errSlots[id] = slot;
49
+ input.addEventListener(input.tagName === "SELECT" ? "change" : "input", () => {
50
+ slot.hidden = true;
51
+ input.removeAttribute("aria-invalid");
52
+ });
53
+ }
54
+
55
+ function clearErrors() {
56
+ errorEl.hidden = true;
57
+ for (const id of FIELD_IDS) {
58
+ errSlots[id].hidden = true;
59
+ el(id).removeAttribute("aria-invalid");
60
+ }
61
+ }
62
+
63
+ // Inline error on a specific field: show the message under it, focus it,
64
+ // and scroll it to mid-screen so the message clears the on-screen keyboard.
65
+ function showFieldError(id, msg) {
66
+ const input = el(id);
67
+ const slot = errSlots[id];
68
+ slot.textContent = msg;
69
+ slot.hidden = false;
70
+ input.setAttribute("aria-invalid", "true");
71
+ input.focus({ preventScroll: true });
72
+ input.closest("label").scrollIntoView({ block: "center", behavior: "smooth" });
73
+ }
74
+
75
+ // Global error (network / server failures — not tied to one field).
76
+ function showFormError(msg) {
77
+ errorEl.textContent = msg;
78
+ errorEl.hidden = false;
79
+ }
80
+
81
+ // Returns [fieldId, message] for the first invalid field, or null.
82
+ function firstInvalid() {
83
+ if (!EMAIL_RE.test(val("kolEmail").toLowerCase())) {
84
+ return ["kolEmail", "Please enter a valid email."];
85
+ }
86
+ for (const [id, label] of REQUIRED) {
87
+ if (!val(id)) return [id, `Please tell us ${label}.`];
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function showSuccess() {
93
+ document.getElementById("formView").hidden = true;
94
+ document.getElementById("formFoot").hidden = true;
95
+ document.getElementById("successView").hidden = false;
96
+ window.scrollTo(0, 0);
97
+ }
98
+
99
+ form.addEventListener("submit", async (event) => {
100
+ event.preventDefault();
101
+ clearErrors();
102
+
103
+ const invalid = firstInvalid();
104
+ if (invalid) {
105
+ showFieldError(invalid[0], invalid[1]);
106
+ return;
107
+ }
108
+
109
+ const fd = new FormData();
110
+ fd.append("kol_email", val("kolEmail").toLowerCase());
111
+ fd.append("received_size", val("receivedSize"));
112
+ fd.append("received_model", val("receivedModel"));
113
+ fd.append("best_fit_finger", val("bestFitFinger"));
114
+ fd.append("fit_quality", val("fitQuality"));
115
+ fd.append("hand", val("hand"));
116
+ fd.append("notes", val("notes"));
117
+ const photo = el("photo").files[0];
118
+ if (photo) fd.append("photo", photo, photo.name);
119
+
120
+ submitBtn.disabled = true;
121
+ const originalLabel = submitBtn.textContent;
122
+ submitBtn.textContent = "Submitting…";
123
+
124
+ try {
125
+ const resp = await fetch("/api/fit-feedback", { method: "POST", body: fd });
126
+ const data = await resp.json().catch(() => ({}));
127
+ if (resp.ok && data.success) {
128
+ showSuccess();
129
+ return;
130
+ }
131
+ showFormError(data.error || "Could not submit. Please try again.");
132
+ } catch (err) {
133
+ showFormError("Network error. Please check your connection and retry.");
134
+ } finally {
135
+ submitBtn.disabled = false;
136
+ submitBtn.textContent = originalLabel;
137
+ }
138
+ });
web_demo/static/mobile/mobile.css CHANGED
@@ -208,6 +208,7 @@ body {
208
  }
209
 
210
  .controls input[type="text"],
 
211
  .controls select {
212
  /* Lock both controls to the same height. iOS Safari's native
213
  <select> renders at a slightly shorter intrinsic height than
 
208
  }
209
 
210
  .controls input[type="text"],
211
+ .controls input[type="email"],
212
  .controls select {
213
  /* Lock both controls to the same height. iOS Safari's native
214
  <select> renders at a slightly shorter intrinsic height than
web_demo/static/mobile/session.js CHANGED
@@ -8,7 +8,7 @@
8
  // next run starts fresh).
9
 
10
  export const session = {
11
- kolName: "",
12
  ringModel: "gen",
13
  // Either an uploaded File or a Blob captured from the camera step.
14
  imageBlob: null,
@@ -26,7 +26,7 @@ export const session = {
26
  };
27
 
28
  // Wipe just the photo + result — used by "Measure again" so the user
29
- // keeps their entered name + ring model on the next capture without
30
  // re-typing. A full reset isn't a separate function: a page refresh
31
  // reloads this module and reinitializes `session` to the defaults.
32
  export function resetForRetake() {
 
8
  // next run starts fresh).
9
 
10
  export const session = {
11
+ kolEmail: "",
12
  ringModel: "gen",
13
  // Either an uploaded File or a Blob captured from the camera step.
14
  imageBlob: null,
 
26
  };
27
 
28
  // Wipe just the photo + result — used by "Measure again" so the user
29
+ // keeps their entered email + ring model on the next capture without
30
  // re-typing. A full reset isn't a separate function: a page refresh
31
  // reloads this module and reinitializes `session` to the defaults.
32
  export function resetForRetake() {
web_demo/static/mobile/steps/confirm.js CHANGED
@@ -94,7 +94,7 @@ export default {
94
  try {
95
  const result = await postMeasure({
96
  blob: session.imageBlob,
97
- kol_name: session.kolName,
98
  ring_model: session.ringModel,
99
  gate_telemetry: session.gateTelemetry,
100
  capture_method: session.imageSource === "camera" ? "camera" : "upload",
 
94
  try {
95
  const result = await postMeasure({
96
  blob: session.imageBlob,
97
+ kol_email: session.kolEmail,
98
  ring_model: session.ringModel,
99
  gate_telemetry: session.gateTelemetry,
100
  capture_method: session.imageSource === "camera" ? "camera" : "upload",
web_demo/static/mobile/steps/form.js CHANGED
@@ -1,6 +1,7 @@
1
- // Step 2 — Name / ID + Ring Model.
2
  // Mirrors the desktop hero-card's top "controls" block.
3
  // Persists answers in session so back-nav preserves them.
 
4
 
5
  import { session } from "../session.js";
6
 
@@ -24,12 +25,13 @@ export default {
24
  </select>
25
  </label>
26
  <label>
27
- <span>Name / ID</span>
28
  <input
29
- type="text"
30
- id="formKolName"
31
- placeholder="e.g. Your Name"
32
- autocomplete="name"
 
33
  />
34
  </label>
35
  </div>
@@ -42,25 +44,31 @@ export default {
42
  </section>
43
  `;
44
 
45
- const nameInput = container.querySelector("#formKolName");
46
  const modelSelect = container.querySelector("#formRingModel");
47
  const errorEl = container.querySelector("#formError");
48
 
49
  // Pre-fill from session — back-nav from upload/camera should not
50
  // ask the user to retype.
51
- nameInput.value = session.kolName || "";
52
  modelSelect.value = session.ringModel || "gen";
53
 
 
 
 
 
 
 
54
  container.querySelector(".step-back").addEventListener("click", nav.back);
55
  container.querySelector(".step-next").addEventListener("click", () => {
56
- const name = nameInput.value.trim();
57
- if (!name) {
58
- errorEl.textContent = "Please enter a name or ID before continuing.";
59
  errorEl.hidden = false;
60
- nameInput.focus();
61
  return;
62
  }
63
- session.kolName = name;
64
  session.ringModel = modelSelect.value;
65
  nav.next();
66
  });
 
1
+ // Step 2 — Email + Ring Model.
2
  // Mirrors the desktop hero-card's top "controls" block.
3
  // Persists answers in session so back-nav preserves them.
4
+ // Email (not name) is the cross-table join key — see doc/v8/PRD.md.
5
 
6
  import { session } from "../session.js";
7
 
 
25
  </select>
26
  </label>
27
  <label>
28
+ <span>Email</span>
29
  <input
30
+ type="email"
31
+ id="formKolEmail"
32
+ placeholder="you@example.com"
33
+ autocomplete="email"
34
+ inputmode="email"
35
  />
36
  </label>
37
  </div>
 
44
  </section>
45
  `;
46
 
47
+ const emailInput = container.querySelector("#formKolEmail");
48
  const modelSelect = container.querySelector("#formRingModel");
49
  const errorEl = container.querySelector("#formError");
50
 
51
  // Pre-fill from session — back-nav from upload/camera should not
52
  // ask the user to retype.
53
+ emailInput.value = session.kolEmail || "";
54
  modelSelect.value = session.ringModel || "gen";
55
 
56
+ // Loose RFC-ish check — `something@something.tld`. The browser's
57
+ // native type="email" validity is the first line of defense; this
58
+ // guards against the field being left blank or obviously malformed
59
+ // since email is the cross-table join key.
60
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
61
+
62
  container.querySelector(".step-back").addEventListener("click", nav.back);
63
  container.querySelector(".step-next").addEventListener("click", () => {
64
+ const email = emailInput.value.trim().toLowerCase();
65
+ if (!EMAIL_RE.test(email)) {
66
+ errorEl.textContent = "Please enter a valid email before continuing.";
67
  errorEl.hidden = false;
68
+ emailInput.focus();
69
  return;
70
  }
71
+ session.kolEmail = email;
72
  session.ringModel = modelSelect.value;
73
  nav.next();
74
  });
web_demo/static/shared/measure-api.js CHANGED
@@ -20,17 +20,17 @@ const DEFAULTS = {
20
 
21
  export async function postMeasure({
22
  blob,
23
- kol_name,
24
  gate_telemetry = null,
25
  ...overrides
26
  } = {}) {
27
  if (!blob) throw new Error("postMeasure: blob is required");
28
- if (!kol_name) throw new Error("postMeasure: kol_name is required");
29
 
30
  const settings = { ...DEFAULTS, ...overrides };
31
  const formData = new FormData();
32
  formData.append("image", blob, "capture.jpg");
33
- formData.append("kol_name", kol_name);
34
  formData.append("finger_index", settings.finger_index);
35
  formData.append("mode", settings.mode);
36
  formData.append("edge_method", settings.edge_method);
 
20
 
21
  export async function postMeasure({
22
  blob,
23
+ kol_email,
24
  gate_telemetry = null,
25
  ...overrides
26
  } = {}) {
27
  if (!blob) throw new Error("postMeasure: blob is required");
28
+ if (!kol_email) throw new Error("postMeasure: kol_email is required");
29
 
30
  const settings = { ...DEFAULTS, ...overrides };
31
  const formData = new FormData();
32
  formData.append("image", blob, "capture.jpg");
33
+ formData.append("kol_email", kol_email);
34
  formData.append("finger_index", settings.finger_index);
35
  formData.append("mode", settings.mode);
36
  formData.append("edge_method", settings.edge_method);
web_demo/static/styles.css CHANGED
@@ -169,7 +169,8 @@ body {
169
  }
170
 
171
  select,
172
- .controls input[type="text"] {
 
173
  border: 1px solid var(--border);
174
  border-radius: 12px;
175
  padding: 10px 12px;
 
169
  }
170
 
171
  select,
172
+ .controls input[type="text"],
173
+ .controls input[type="email"] {
174
  border: 1px solid var(--border);
175
  border-radius: 12px;
176
  padding: 10px 12px;
web_demo/supabase_client.py CHANGED
@@ -53,6 +53,14 @@ def _get_client():
53
  return _client
54
 
55
 
 
 
 
 
 
 
 
 
56
  BUCKET = "ring-measurements"
57
 
58
 
@@ -101,6 +109,46 @@ def save_measurement(record: Dict[str, Any]) -> Optional[str]:
101
  return None
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
105
  """Fetch measurements for admin page, newest first."""
106
  client = _get_client()
@@ -121,7 +169,7 @@ def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]
121
 
122
 
123
  STATS_COLUMNS = (
124
- "id,created_at,kol_name,mode,ring_model,confidence,fail_reason,"
125
  "overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size,"
126
  "finger_index,photo_url,per_finger,feedback_rating,feedback_message"
127
  )
 
53
  return _client
54
 
55
 
56
+ def persistence_enabled() -> bool:
57
+ """True when Supabase persistence is active (env configured and not
58
+ opted out via RING_DISABLE_SUPABASE). Lets an endpoint distinguish
59
+ 'disabled in this env' (still report success to the user) from a real
60
+ write failure (surface an error)."""
61
+ return _get_client() is not None
62
+
63
+
64
  BUCKET = "ring-measurements"
65
 
66
 
 
109
  return None
110
 
111
 
112
+ def save_feedback(record: Dict[str, Any]) -> Optional[str]:
113
+ """Insert a post-shipment fit-feedback row into the `feedback` table.
114
+
115
+ Intentionally decoupled from `measurements` (no run_id, no FK) — the
116
+ only link between the two is the normalized `kol_email`, joined at
117
+ analysis time. See doc/v8/PRD.md. Returns row UUID or None (None also
118
+ when persistence is disabled, which the caller treats as success).
119
+ """
120
+ client = _get_client()
121
+ if client is None:
122
+ return None
123
+ try:
124
+ resp = client.table("feedback").insert(record).execute()
125
+ if resp.data and len(resp.data) > 0:
126
+ return resp.data[0].get("id")
127
+ return None
128
+ except Exception as e:
129
+ logger.error("Failed to save feedback: %s", e)
130
+ return None
131
+
132
+
133
+ def list_feedback(limit: int = 500, offset: int = 0) -> List[Dict[str, Any]]:
134
+ """Fetch post-shipment feedback rows for the admin page, newest first."""
135
+ client = _get_client()
136
+ if client is None:
137
+ return []
138
+ try:
139
+ resp = (
140
+ client.table("feedback")
141
+ .select("*")
142
+ .order("submitted_at", desc=True)
143
+ .range(offset, offset + limit - 1)
144
+ .execute()
145
+ )
146
+ return resp.data or []
147
+ except Exception as e:
148
+ logger.error("Failed to list feedback: %s", e)
149
+ return []
150
+
151
+
152
  def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
153
  """Fetch measurements for admin page, newest first."""
154
  client = _get_client()
 
169
 
170
 
171
  STATS_COLUMNS = (
172
+ "id,created_at,kol_name,kol_email,mode,ring_model,confidence,fail_reason,"
173
  "overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size,"
174
  "finger_index,photo_url,per_finger,feedback_rating,feedback_message"
175
  )
web_demo/templates/admin.html CHANGED
@@ -175,6 +175,7 @@
175
  <div class="tabs">
176
  <button class="tab active" data-pane="recordsPane">Records</button>
177
  <button class="tab" data-pane="dashboardPane">Dashboard</button>
 
178
  </div>
179
 
180
  <!-- Records pane -->
@@ -265,6 +266,36 @@
265
  </div>
266
  </div>
267
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  </div>
269
 
270
  <script>
@@ -294,6 +325,7 @@
294
  document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
295
  loadStats();
296
  loadData();
 
297
  return true;
298
  };
299
 
@@ -357,7 +389,7 @@
357
  ? `<img class="thumb" loading="lazy" src="${r.result_url}" onclick="window.open('${r.result_url}')" />`
358
  : "-";
359
  return `<tr data-id="${r.id}">
360
- <td><strong>${r.kol_name || "-"}</strong></td>
361
  <td>${fmtDate(r.created_at)}</td>
362
  <td>${r.ring_model || "-"}</td>
363
  <td>${photoThumb}</td>
@@ -445,6 +477,68 @@
445
  });
446
  });
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  // ------------------------------------------------------------------
449
  // Dashboard
450
  // ------------------------------------------------------------------
@@ -504,7 +598,7 @@
504
  }
505
  topKolList.innerHTML = kols.map((k) => {
506
  const last = k.last_at ? new Date(k.last_at).toISOString().slice(0, 10) : "";
507
- return `<li><span class="name">${k.kol_name}</span><span class="count">${k.count} · ${last}</span></li>`;
508
  }).join("");
509
  };
510
 
 
175
  <div class="tabs">
176
  <button class="tab active" data-pane="recordsPane">Records</button>
177
  <button class="tab" data-pane="dashboardPane">Dashboard</button>
178
+ <button class="tab" data-pane="feedbackPane">Feedback</button>
179
  </div>
180
 
181
  <!-- Records pane -->
 
266
  </div>
267
  </div>
268
  </div>
269
+
270
+ <!-- Feedback pane (v8 post-shipment fit feedback) -->
271
+ <div class="pane" id="feedbackPane">
272
+ <div class="toolbar">
273
+ <a href="/">Back to Demo</a>
274
+ <button id="fbRefreshBtn">Refresh</button>
275
+ <span class="count" id="fbCountLabel">Loading...</span>
276
+ </div>
277
+
278
+ <div class="scroll-wrap">
279
+ <table>
280
+ <thead>
281
+ <tr>
282
+ <th>Email</th>
283
+ <th>Submitted</th>
284
+ <th>Size</th>
285
+ <th>Model</th>
286
+ <th>Best finger</th>
287
+ <th>Fit</th>
288
+ <th>Hand</th>
289
+ <th>Photo</th>
290
+ <th>Notes</th>
291
+ </tr>
292
+ </thead>
293
+ <tbody id="fbTableBody">
294
+ <tr><td colspan="9" class="empty">Loading...</td></tr>
295
+ </tbody>
296
+ </table>
297
+ </div>
298
+ </div>
299
  </div>
300
 
301
  <script>
 
325
  document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
326
  loadStats();
327
  loadData();
328
+ loadFeedback();
329
  return true;
330
  };
331
 
 
389
  ? `<img class="thumb" loading="lazy" src="${r.result_url}" onclick="window.open('${r.result_url}')" />`
390
  : "-";
391
  return `<tr data-id="${r.id}">
392
+ <td><strong>${r.kol_email || r.kol_name || "-"}</strong></td>
393
  <td>${fmtDate(r.created_at)}</td>
394
  <td>${r.ring_model || "-"}</td>
395
  <td>${photoThumb}</td>
 
477
  });
478
  });
479
 
480
+ // ------------------------------------------------------------------
481
+ // Feedback (v8 post-shipment fit feedback)
482
+ // ------------------------------------------------------------------
483
+ const fbTableBody = document.getElementById("fbTableBody");
484
+ const fbCountLabel = document.getElementById("fbCountLabel");
485
+
486
+ // Map the stored value codes back to human labels (mirrors the
487
+ // /feedback form's <option> text).
488
+ const FB_MODEL = { gen: "Gen1/Gen2", air: "Air" };
489
+ const FB_FINGER = {
490
+ thumb: "Thumb", index: "Index", middle: "Middle", ring: "Ring",
491
+ pinky: "Pinky", none_too_small: "None (too small)", none_too_big: "None (too big)",
492
+ };
493
+ const FB_FIT = {
494
+ perfect: "Perfect", snug: "Snug", loose: "Loose but stays on",
495
+ falls_off: "Falls off", too_tight: "Can't get it on",
496
+ };
497
+ const FB_HAND = { left: "Left", right: "Right" };
498
+ const fbLabel = (map, v) => (v ? (map[v] || v) : "-");
499
+
500
+ const fbRowHtml = (r) => {
501
+ const photoThumb = r.photo_url
502
+ ? `<img class="thumb" loading="lazy" src="${esc(r.photo_url)}" onclick="window.open('${esc(r.photo_url)}')" />`
503
+ : "-";
504
+ return `<tr>
505
+ <td><strong>${esc(r.kol_email) || "-"}</strong></td>
506
+ <td>${fmtDate(r.submitted_at)}</td>
507
+ <td>${esc(r.received_size) || "-"}</td>
508
+ <td>${fbLabel(FB_MODEL, r.received_model)}</td>
509
+ <td>${fbLabel(FB_FINGER, r.best_fit_finger)}</td>
510
+ <td>${fbLabel(FB_FIT, r.fit_quality)}</td>
511
+ <td>${fbLabel(FB_HAND, r.hand)}</td>
512
+ <td>${photoThumb}</td>
513
+ <td>${fmtComment(r.notes)}</td>
514
+ </tr>`;
515
+ };
516
+
517
+ const loadFeedback = async () => {
518
+ try {
519
+ const resp = await fetch(`/api/admin/feedback?token=${encodeURIComponent(adminToken)}`);
520
+ if (resp.status === 401) {
521
+ sessionStorage.removeItem("admin_token");
522
+ loginGate.style.display = "";
523
+ adminContent.style.display = "none";
524
+ loginError.textContent = "Session expired. Please log in again.";
525
+ return;
526
+ }
527
+ const rows = await resp.json();
528
+ if (!rows.length) {
529
+ fbCountLabel.textContent = "0 feedback rows";
530
+ fbTableBody.innerHTML = '<tr><td colspan="9" class="empty">No feedback yet</td></tr>';
531
+ return;
532
+ }
533
+ fbCountLabel.textContent = `${rows.length} feedback rows`;
534
+ fbTableBody.innerHTML = rows.map(fbRowHtml).join("");
535
+ } catch (e) {
536
+ fbTableBody.innerHTML = `<tr><td colspan="9" class="empty">Error loading feedback: ${e.message}</td></tr>`;
537
+ }
538
+ };
539
+
540
+ document.getElementById("fbRefreshBtn").addEventListener("click", loadFeedback);
541
+
542
  // ------------------------------------------------------------------
543
  // Dashboard
544
  // ------------------------------------------------------------------
 
598
  }
599
  topKolList.innerHTML = kols.map((k) => {
600
  const last = k.last_at ? new Date(k.last_at).toISOString().slice(0, 10) : "";
601
+ return `<li><span class="name">${k.kol_ident}</span><span class="count">${k.count} · ${last}</span></li>`;
602
  }).join("");
603
  };
604
 
web_demo/templates/feedback.html ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#f5f1e7" />
7
+ <title>Ring Fit Feedback — Femometer Smart Ring</title>
8
+ <meta name="description" content="Tell us how your ring fit so we can improve sizing." />
9
+ <meta name="robots" content="noindex" />
10
+ <!-- Reuse the /m flow's stylesheet so this form matches the mobile
11
+ capture flow's look; feedback.css only adds the success screen +
12
+ a couple of form niceties the step CSS doesn't cover. -->
13
+ <link rel="stylesheet" href="/static/mobile/mobile.css" />
14
+ <link rel="stylesheet" href="/static/feedback/feedback.css" />
15
+ </head>
16
+ <body>
17
+ <main class="step step-feedback">
18
+ <!-- Form view -->
19
+ <div class="step-body" id="formView">
20
+ <p class="hero-eyebrow">Femometer Smart Ring</p>
21
+ <h1 class="hero-headline">How did your ring fit?</h1>
22
+
23
+ <form class="panel" id="feedbackForm" novalidate>
24
+ <div class="controls">
25
+ <label>
26
+ <span>Email</span>
27
+ <input
28
+ type="email"
29
+ id="kolEmail"
30
+ placeholder="you@example.com"
31
+ autocomplete="email"
32
+ inputmode="email"
33
+ required
34
+ />
35
+ </label>
36
+
37
+ <label>
38
+ <span>Ring size received</span>
39
+ <input
40
+ type="text"
41
+ id="receivedSize"
42
+ placeholder="e.g. 7 (read it off the package)"
43
+ autocomplete="off"
44
+ />
45
+ </label>
46
+
47
+ <label>
48
+ <span>Ring model</span>
49
+ <select id="receivedModel">
50
+ <option value="" disabled selected>Choose…</option>
51
+ <option value="gen">Gen1/Gen2</option>
52
+ <option value="air">Air</option>
53
+ </select>
54
+ </label>
55
+
56
+ <label>
57
+ <span>Which finger did it fit best?</span>
58
+ <select id="bestFitFinger">
59
+ <option value="" disabled selected>Choose…</option>
60
+ <option value="index">Index</option>
61
+ <option value="middle">Middle</option>
62
+ <option value="ring">Ring</option>
63
+ <option value="pinky">Pinky</option>
64
+ <option value="thumb">Thumb</option>
65
+ <option value="none_too_small">None — too small for all</option>
66
+ <option value="none_too_big">None — too big for all</option>
67
+ </select>
68
+ </label>
69
+
70
+ <label>
71
+ <span>How did it fit on that finger?</span>
72
+ <select id="fitQuality">
73
+ <option value="" disabled selected>Choose…</option>
74
+ <option value="perfect">Perfect</option>
75
+ <option value="snug">Snug</option>
76
+ <option value="loose">Loose but stays on</option>
77
+ <option value="falls_off">Falls off</option>
78
+ <option value="too_tight">Can't get it on</option>
79
+ </select>
80
+ </label>
81
+
82
+ <label>
83
+ <span>Which hand?</span>
84
+ <select id="hand">
85
+ <option value="" disabled selected>Choose…</option>
86
+ <option value="left">Left</option>
87
+ <option value="right">Right</option>
88
+ </select>
89
+ </label>
90
+
91
+ <label>
92
+ <span>Photo of the ring on your finger (optional)</span>
93
+ <input type="file" id="photo" accept="image/*" />
94
+ </label>
95
+
96
+ <label>
97
+ <span>Anything else? (optional)</span>
98
+ <textarea
99
+ id="notes"
100
+ rows="3"
101
+ placeholder="Tell us anything the questions above missed."
102
+ ></textarea>
103
+ </label>
104
+ </div>
105
+
106
+ <p class="form-error" id="formError" hidden></p>
107
+ </form>
108
+ </div>
109
+
110
+ <div class="step-foot" id="formFoot">
111
+ <button type="submit" class="primary" id="submitBtn" form="feedbackForm">
112
+ Submit feedback
113
+ </button>
114
+ </div>
115
+
116
+ <!-- Success view (swapped in on submit) -->
117
+ <div class="step-body fb-success" id="successView" hidden>
118
+ <div class="fb-success-inner">
119
+ <div class="fb-success-check">✓</div>
120
+ <h1 class="hero-headline">Thank you!</h1>
121
+ <p class="hero-sub">
122
+ Your feedback is in. It directly helps us improve ring-size
123
+ accuracy.
124
+ </p>
125
+ </div>
126
+ </div>
127
+ </main>
128
+
129
+ <script type="module" src="/static/feedback/feedback.js"></script>
130
+ </body>
131
+ </html>
web_demo/templates/index.html CHANGED
@@ -43,8 +43,8 @@
43
 
44
  <div class="controls">
45
  <label>
46
- <span>Name / ID</span>
47
- <input type="text" name="kol_name" id="kolNameInput" placeholder="e.g. Your Name" />
48
  </label>
49
  <label>
50
  <span>Ring Model</span>
 
43
 
44
  <div class="controls">
45
  <label>
46
+ <span>Email</span>
47
+ <input type="email" name="kol_email" id="kolEmailInput" placeholder="you@example.com" autocomplete="email" inputmode="email" />
48
  </label>
49
  <label>
50
  <span>Ring Model</span>