feng-x commited on
Commit
dddedae
Β·
verified Β·
1 Parent(s): 1d0ddd4

Upload folder using huggingface_hub

Browse files
AGENTS.md CHANGED
@@ -195,6 +195,27 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  ---
199
 
200
  ## Important Technical Details
 
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
 
198
+ ### Post-result feedback (rating + comment)
199
+
200
+ After a successful run both surfaces show a "How did it go?" panel (5-star rating + optional comment). `POST /api/feedback` attaches them to the existing `measurements` row via `run_id` β€” there is no separate `feedback` table. Two columns on `measurements`:
201
+
202
+ | Column | Type | Notes |
203
+ |---|---|---|
204
+ | `feedback_rating` | `int` (1–5, NULL allowed) | API validates the range; no DB constraint. |
205
+ | `feedback_message` | `text` (NULL allowed) | Server caps length at `FEEDBACK_MAX_MESSAGE_LEN = 4000`. |
206
+
207
+ The endpoint only patches columns the user actually provided (partial submissions don't NULL the other column), retries 6 Γ— 500 ms to absorb the race with the async measurement insert, and returns a real `404` if the row truly isn't there after the retries.
208
+
209
+ The admin dashboard surfaces this as an **Avg rating** stat card, a **User Ratings** 1β˜…β€“5β˜… distribution chart, and per-row **Rating** + **Comment** columns in the records table. `_compute_stats` filters feedback aggregation by `not fail_reason` so any pre-existing ratings on failed runs don't skew `avg_rating`.
210
+
211
+ The columns were added to the live DB via the Supabase Management API (the repo has no migrations dir); equivalent SQL:
212
+
213
+ ```sql
214
+ alter table public.measurements
215
+ add column if not exists feedback_rating int,
216
+ add column if not exists feedback_message text;
217
+ ```
218
+
219
  ---
220
 
221
  ## Important Technical Details
CLAUDE.md CHANGED
@@ -195,6 +195,27 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  ---
199
 
200
  ## Important Technical Details
 
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
 
198
+ ### Post-result feedback (rating + comment)
199
+
200
+ After a successful run both surfaces show a "How did it go?" panel (5-star rating + optional comment). `POST /api/feedback` attaches them to the existing `measurements` row via `run_id` β€” there is no separate `feedback` table. Two columns on `measurements`:
201
+
202
+ | Column | Type | Notes |
203
+ |---|---|---|
204
+ | `feedback_rating` | `int` (1–5, NULL allowed) | API validates the range; no DB constraint. |
205
+ | `feedback_message` | `text` (NULL allowed) | Server caps length at `FEEDBACK_MAX_MESSAGE_LEN = 4000`. |
206
+
207
+ The endpoint only patches columns the user actually provided (partial submissions don't NULL the other column), retries 6 Γ— 500 ms to absorb the race with the async measurement insert, and returns a real `404` if the row truly isn't there after the retries.
208
+
209
+ The admin dashboard surfaces this as an **Avg rating** stat card, a **User Ratings** 1β˜…β€“5β˜… distribution chart, and per-row **Rating** + **Comment** columns in the records table. `_compute_stats` filters feedback aggregation by `not fail_reason` so any pre-existing ratings on failed runs don't skew `avg_rating`.
210
+
211
+ The columns were added to the live DB via the Supabase Management API (the repo has no migrations dir); equivalent SQL:
212
+
213
+ ```sql
214
+ alter table public.measurements
215
+ add column if not exists feedback_rating int,
216
+ add column if not exists feedback_message text;
217
+ ```
218
+
219
  ---
220
 
221
  ## Important Technical Details
README.md CHANGED
@@ -26,6 +26,9 @@ Local computer-vision CLI tool that measures **finger outer diameter** from a si
26
  - **Ring size recommendation** maps calibrated diameter to sizes 6–13 (best match + 2-size range). Supports multiple ring models: **Gen1/Gen2** and **Air**, each with its own size chart.
27
  - **Multi-finger mode** measures index, middle, and ring fingers in one pass; consensus aggregation maximizes the chance at least one finger fits.
28
  - **Optional AI explanation** (OpenAI) generates a human-readable rationale for the recommendation (size selection is always deterministic).
 
 
 
29
  - Writes JSON output and always writes a result PNG next to it.
30
 
31
  ## Accuracy
@@ -150,7 +153,9 @@ Notes:
150
  | [`doc/v1/`](doc/v1/) | v1 PRD, Plan, Progress (Sobel edge refinement; `auto`/`compare` modes later removed) |
151
  | [`doc/v2/`](doc/v2/) | v2 Plan, Progress (calibration & regression) |
152
  | [`doc/v3/`](doc/v3/) | v3 Progress (multi-finger, quality checks, AI explanation) |
153
- | [`doc/v4/`](doc/v4/) | v4 PRD, Plan, Progress (SAM 2.1 card + hand segmentation β€” **current architecture**) |
 
 
154
  | [`doc/report/`](doc/report/) | Validation, calibration & ring size mapping reports |
155
  | [`doc/algorithms/`](doc/algorithms/) | Algorithm documentation |
156
  | [`script/`](script/) | Batch measurement & analysis scripts |
 
26
  - **Ring size recommendation** maps calibrated diameter to sizes 6–13 (best match + 2-size range). Supports multiple ring models: **Gen1/Gen2** and **Air**, each with its own size chart.
27
  - **Multi-finger mode** measures index, middle, and ring fingers in one pass; consensus aggregation maximizes the chance at least one finger fits.
28
  - **Optional AI explanation** (OpenAI) generates a human-readable rationale for the recommendation (size selection is always deterministic).
29
+ - **In-browser capture coach** (v5) opens the camera in-page and gates the shutter on live distance / level / brightness checks, eliminating bad-photo round trips.
30
+ - **Mobile-native flow** (v6) at `/m` paginates the demo into six steps (intro β†’ form β†’ guide β†’ capture β†’ confirm β†’ result); `/` auto-routes mobile UAs there.
31
+ - **Post-result feedback** β€” successful runs get a 5-star rating + optional comment panel; data is attached to the measurement row and surfaced on the `/admin` dashboard (avg rating, rating distribution, per-row Rating and Comment columns).
32
  - Writes JSON output and always writes a result PNG next to it.
33
 
34
  ## Accuracy
 
153
  | [`doc/v1/`](doc/v1/) | v1 PRD, Plan, Progress (Sobel edge refinement; `auto`/`compare` modes later removed) |
154
  | [`doc/v2/`](doc/v2/) | v2 Plan, Progress (calibration & regression) |
155
  | [`doc/v3/`](doc/v3/) | v3 Progress (multi-finger, quality checks, AI explanation) |
156
+ | [`doc/v4/`](doc/v4/) | v4 PRD, Plan, Progress (SAM 2.1 card + hand segmentation) |
157
+ | [`doc/v5/`](doc/v5/) | v5 PRD, Plan, Progress (in-browser capture coach: live distance/level/brightness gates) |
158
+ | [`doc/v6/`](doc/v6/) | v6 PRD, Plan, Progress (mobile-native paginated flow at `/m` β€” **current surface**) |
159
  | [`doc/report/`](doc/report/) | Validation, calibration & ring size mapping reports |
160
  | [`doc/algorithms/`](doc/algorithms/) | Algorithm documentation |
161
  | [`script/`](script/) | Batch measurement & analysis scripts |
web_demo/app.py CHANGED
@@ -16,6 +16,7 @@ import sys
16
  import uuid
17
  from collections import Counter, defaultdict
18
  from concurrent.futures import ThreadPoolExecutor
 
19
  from datetime import date, datetime, timedelta, timezone
20
  from pathlib import Path
21
  from typing import Any, Dict, List, Optional, Tuple
@@ -35,6 +36,10 @@ from src.ai_recommendation import ai_explain_recommendation
35
  from web_demo.supabase_client import (
36
  upload_file,
37
  save_measurement,
 
 
 
 
38
  list_measurements,
39
  list_measurements_for_stats,
40
  update_ground_truth,
@@ -356,6 +361,7 @@ def _run_measurement(
356
  "result_image_url": f"/results/{result_png_name}",
357
  "input_image_url": input_image_url,
358
  "result_json_url": f"/results/{result_json_name}",
 
359
  }
360
 
361
  # Persist to Supabase in the background β€” do not block the response on
@@ -457,6 +463,7 @@ def _run_multi_measurement(
457
  "result_image_url": f"/results/{result_png_name}",
458
  "input_image_url": input_image_url,
459
  "result_json_url": f"/results/{result_json_name}",
 
460
  }
461
 
462
  # Persist to Supabase in the background.
@@ -483,6 +490,74 @@ def _run_multi_measurement(
483
  return jsonify(payload)
484
 
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  # ---------------------------------------------------------------------------
487
  # Admin routes
488
  # ---------------------------------------------------------------------------
@@ -571,6 +646,7 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
571
  per_day_fails: Counter = Counter()
572
 
573
  kol_counts: Counter = Counter()
 
574
  kol_last_seen: Dict[str, str] = {}
575
  kol_display: Dict[str, str] = {}
576
  fail_counter: Counter = Counter()
@@ -582,9 +658,13 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
582
  confidence_sum = 0.0
583
  confidence_n = 0
584
 
 
 
 
 
 
585
  success_count = 0
586
  fail_count = 0
587
- gt_filled_count = 0
588
 
589
  last_7_count = 0
590
  prev_7_count = 0
@@ -605,12 +685,15 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
605
  d = _parse_iso_to_utc_date(created_at)
606
 
607
  kkey = _kol_key(row.get("kol_name"))
 
608
  if kkey:
609
  kol_counts[kkey] += 1
610
  if kkey not in kol_display:
611
  kol_display[kkey] = (row.get("kol_name") or "").strip()
612
- if created_at and (kkey not in kol_last_seen or created_at > kol_last_seen[kkey]):
613
- kol_last_seen[kkey] = created_at
 
 
614
 
615
  fail_reason = row.get("fail_reason")
616
  if fail_reason:
@@ -651,8 +734,19 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
651
  else:
652
  confidence_buckets["LOW"] += 1
653
 
654
- if any(row.get(k) not in (None, "") for k in ("gt_index_size", "gt_middle_size", "gt_ring_size")):
655
- gt_filled_count += 1
 
 
 
 
 
 
 
 
 
 
 
656
 
657
  if d is not None:
658
  if window_start <= d <= today:
@@ -684,7 +778,7 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
684
  "count": c,
685
  "last_at": kol_last_seen.get(k),
686
  }
687
- for k, c in kol_counts.most_common(10)
688
  ]
689
 
690
  fail_reasons = [
@@ -714,7 +808,7 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
714
  total = len(rows)
715
  success_rate = (success_count / total) if total else 0.0
716
  avg_confidence = (confidence_sum / confidence_n) if confidence_n else 0.0
717
- gt_rate = (gt_filled_count / total) if total else 0.0
718
 
719
  return {
720
  "totals": {
@@ -724,13 +818,17 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
724
  "fail_count": fail_count,
725
  "success_rate": round(success_rate, 4),
726
  "avg_confidence": round(avg_confidence, 4),
727
- "gt_filled_count": gt_filled_count,
728
- "gt_filled_rate": round(gt_rate, 4),
 
729
  "last_7_days": last_7_count,
730
  "prev_7_days": prev_7_count,
731
  "first_measurement_at": first_at,
732
  "last_measurement_at": last_at,
733
  },
 
 
 
734
  "window_days": days,
735
  "per_day": per_day_series,
736
  "top_kols": top_kols,
@@ -758,6 +856,26 @@ def api_admin_stats():
758
  return jsonify(_compute_stats(rows, days=days))
759
 
760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  @app.route("/api/admin/export-csv")
762
  def api_admin_export_csv():
763
  if not _check_admin_token():
@@ -772,13 +890,14 @@ def api_admin_export_csv():
772
  "ring_size", "ring_diameter", "ring_confidence",
773
  "confidence", "fail_reason", "ai_explanation",
774
  "ring_fit", "gt_best_finger", "gt_index_size", "gt_middle_size", "gt_ring_size", "gt_notes",
 
775
  "photo_url", "result_url", "id",
776
  ]
777
  writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
778
  writer.writeheader()
779
  for row in rows:
780
  flat = {
781
- "kol_name": row.get("kol_name", ""),
782
  "created_at": row.get("created_at", ""),
783
  "mode": row.get("mode", ""),
784
  "ring_model": row.get("ring_model", ""),
@@ -787,13 +906,15 @@ def api_admin_export_csv():
787
  "overall_range_max": row.get("overall_range_max", ""),
788
  "confidence": row.get("confidence", ""),
789
  "fail_reason": row.get("fail_reason", ""),
790
- "ai_explanation": (row.get("result_json") or {}).get("ai_explanation", ""),
791
- "ring_fit": row.get("ring_fit", ""),
792
- "gt_best_finger": row.get("gt_best_finger", ""),
793
  "gt_index_size": row.get("gt_index_size", ""),
794
  "gt_middle_size": row.get("gt_middle_size", ""),
795
  "gt_ring_size": row.get("gt_ring_size", ""),
796
- "gt_notes": row.get("gt_notes", ""),
 
 
797
  "photo_url": row.get("photo_url", ""),
798
  "result_url": row.get("result_url", ""),
799
  "id": row.get("id", ""),
 
16
  import uuid
17
  from collections import Counter, defaultdict
18
  from concurrent.futures import ThreadPoolExecutor
19
+ import time
20
  from datetime import date, datetime, timedelta, timezone
21
  from pathlib import Path
22
  from typing import Any, Dict, List, Optional, Tuple
 
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,
 
361
  "result_image_url": f"/results/{result_png_name}",
362
  "input_image_url": input_image_url,
363
  "result_json_url": f"/results/{result_json_name}",
364
+ "run_id": run_id,
365
  }
366
 
367
  # Persist to Supabase in the background β€” do not block the response on
 
463
  "result_image_url": f"/results/{result_png_name}",
464
  "input_image_url": input_image_url,
465
  "result_json_url": f"/results/{result_json_name}",
466
+ "run_id": run_id,
467
  }
468
 
469
  # Persist to Supabase in the background.
 
490
  return jsonify(payload)
491
 
492
 
493
+ FEEDBACK_MAX_MESSAGE_LEN = 4000
494
+ # The measurement row is persisted asynchronously (storage uploads + insert
495
+ # take ~1–5 s on a healthy connection), so a fast-finger user can submit
496
+ # feedback before the row exists. Retry briefly to absorb that race before
497
+ # returning an error.
498
+ FEEDBACK_RETRY_ATTEMPTS = 6
499
+ FEEDBACK_RETRY_DELAY_S = 0.5
500
+
501
+
502
+ @app.route("/api/feedback", methods=["POST"])
503
+ def api_feedback():
504
+ data = request.get_json(silent=True) or request.form
505
+ run_id = (data.get("run_id") or "").strip()
506
+ if not run_id:
507
+ return jsonify({"success": False, "error": "run_id is required"}), 400
508
+
509
+ rating_raw = data.get("rating")
510
+ rating: Optional[int] = None
511
+ if rating_raw not in (None, ""):
512
+ # Reject booleans (True/False are int subclasses in Python) and
513
+ # floats (a malicious or buggy client sending 4.9 would otherwise
514
+ # round-trip as 4 after int()). Accept int and str shapes only.
515
+ if isinstance(rating_raw, bool) or isinstance(rating_raw, float):
516
+ return jsonify({"success": False, "error": "rating must be an integer 1–5"}), 400
517
+ try:
518
+ rating = int(rating_raw)
519
+ except (TypeError, ValueError):
520
+ return jsonify({"success": False, "error": "rating must be an integer 1–5"}), 400
521
+ if rating < 1 or rating > 5:
522
+ return jsonify({"success": False, "error": "rating must be between 1 and 5"}), 400
523
+
524
+ message = (data.get("message") or "").strip()[:FEEDBACK_MAX_MESSAGE_LEN] or None
525
+
526
+ if rating is None and not message:
527
+ return jsonify({"success": False, "error": "Provide a rating or a message"}), 400
528
+
529
+ # Only patch the columns the user actually provided β€” sending
530
+ # {feedback_rating: rating, feedback_message: message} unconditionally
531
+ # would NULL out the other column on a follow-up partial submission.
532
+ updates: Dict[str, Any] = {}
533
+ if rating is not None:
534
+ updates["feedback_rating"] = rating
535
+ if message is not None:
536
+ updates["feedback_message"] = message
537
+
538
+ status = update_measurement_feedback(run_id, updates)
539
+ if status == FEEDBACK_NO_ROW:
540
+ # Race with the async measurement insert: row may not exist yet.
541
+ # Bounded retry; if still missing afterwards, surface a real error.
542
+ for _ in range(FEEDBACK_RETRY_ATTEMPTS - 1):
543
+ time.sleep(FEEDBACK_RETRY_DELAY_S)
544
+ status = update_measurement_feedback(run_id, updates)
545
+ if status != FEEDBACK_NO_ROW:
546
+ break
547
+
548
+ if status == FEEDBACK_OK or status == FEEDBACK_DISABLED:
549
+ # DISABLED means RING_DISABLE_SUPABASE is set (local dev) β€” treat
550
+ # as success since the user-facing action did what it could.
551
+ return jsonify({"success": True})
552
+
553
+ if status == FEEDBACK_NO_ROW:
554
+ logger.warning("Feedback not attached: no row for run %s", run_id)
555
+ return jsonify({"success": False, "error": "Measurement not found"}), 404
556
+
557
+ # FEEDBACK_ERROR β€” Supabase raised; supabase_client already logged it.
558
+ return jsonify({"success": False, "error": "Could not save feedback, please retry"}), 500
559
+
560
+
561
  # ---------------------------------------------------------------------------
562
  # Admin routes
563
  # ---------------------------------------------------------------------------
 
646
  per_day_fails: Counter = Counter()
647
 
648
  kol_counts: Counter = Counter()
649
+ kol_counts_window: Counter = Counter()
650
  kol_last_seen: Dict[str, str] = {}
651
  kol_display: Dict[str, str] = {}
652
  fail_counter: Counter = Counter()
 
658
  confidence_sum = 0.0
659
  confidence_n = 0
660
 
661
+ rating_buckets: Counter = Counter()
662
+ rating_sum = 0.0
663
+ rating_n = 0
664
+ comment_count = 0
665
+
666
  success_count = 0
667
  fail_count = 0
 
668
 
669
  last_7_count = 0
670
  prev_7_count = 0
 
685
  d = _parse_iso_to_utc_date(created_at)
686
 
687
  kkey = _kol_key(row.get("kol_name"))
688
+ in_window = d is not None and window_start <= d <= today
689
  if kkey:
690
  kol_counts[kkey] += 1
691
  if kkey not in kol_display:
692
  kol_display[kkey] = (row.get("kol_name") or "").strip()
693
+ if in_window:
694
+ kol_counts_window[kkey] += 1
695
+ if created_at and (kkey not in kol_last_seen or created_at > kol_last_seen[kkey]):
696
+ kol_last_seen[kkey] = created_at
697
 
698
  fail_reason = row.get("fail_reason")
699
  if fail_reason:
 
734
  else:
735
  confidence_buckets["LOW"] += 1
736
 
737
+ # Only count feedback on successful runs β€” feedback attached to a
738
+ # failed measurement reflects "the tool didn't work for me," not a
739
+ # rating of the measurement itself, so including it would poison
740
+ # avg_rating. Going forward the UI also hides the panel on failure,
741
+ # but this filter cleans up any pre-existing rows from when it didn't.
742
+ if not fail_reason:
743
+ rating = row.get("feedback_rating")
744
+ if isinstance(rating, int) and 1 <= rating <= 5:
745
+ rating_sum += rating
746
+ rating_n += 1
747
+ rating_buckets[rating] += 1
748
+ if (row.get("feedback_message") or "").strip():
749
+ comment_count += 1
750
 
751
  if d is not None:
752
  if window_start <= d <= today:
 
778
  "count": c,
779
  "last_at": kol_last_seen.get(k),
780
  }
781
+ for k, c in kol_counts_window.most_common(10)
782
  ]
783
 
784
  fail_reasons = [
 
808
  total = len(rows)
809
  success_rate = (success_count / total) if total else 0.0
810
  avg_confidence = (confidence_sum / confidence_n) if confidence_n else 0.0
811
+ avg_rating = (rating_sum / rating_n) if rating_n else 0.0
812
 
813
  return {
814
  "totals": {
 
818
  "fail_count": fail_count,
819
  "success_rate": round(success_rate, 4),
820
  "avg_confidence": round(avg_confidence, 4),
821
+ "avg_rating": round(avg_rating, 2),
822
+ "rating_count": rating_n,
823
+ "comment_count": comment_count,
824
  "last_7_days": last_7_count,
825
  "prev_7_days": prev_7_count,
826
  "first_measurement_at": first_at,
827
  "last_measurement_at": last_at,
828
  },
829
+ "rating_distribution": [
830
+ {"stars": str(n), "count": rating_buckets.get(n, 0)} for n in (1, 2, 3, 4, 5)
831
+ ],
832
  "window_days": days,
833
  "per_day": per_day_series,
834
  "top_kols": top_kols,
 
856
  return jsonify(_compute_stats(rows, days=days))
857
 
858
 
859
+ _CSV_INJECTION_LEAD = ("=", "+", "-", "@", "\t", "\r")
860
+
861
+
862
+ def _csv_safe(value: Any) -> str:
863
+ """Defang CSV-injection vectors in user-supplied cell values.
864
+
865
+ Excel / Google Sheets treat a leading `=`, `+`, `-`, or `@` as a
866
+ formula trigger. Prefixing a single quote turns the cell into plain
867
+ text without changing how it displays once the user removes the
868
+ quote, so the data round-trips back to whatever shape the user
869
+ expected.
870
+ """
871
+ if value is None:
872
+ return ""
873
+ s = str(value)
874
+ if s and s[0] in _CSV_INJECTION_LEAD:
875
+ return "'" + s
876
+ return s
877
+
878
+
879
  @app.route("/api/admin/export-csv")
880
  def api_admin_export_csv():
881
  if not _check_admin_token():
 
890
  "ring_size", "ring_diameter", "ring_confidence",
891
  "confidence", "fail_reason", "ai_explanation",
892
  "ring_fit", "gt_best_finger", "gt_index_size", "gt_middle_size", "gt_ring_size", "gt_notes",
893
+ "feedback_rating", "feedback_message",
894
  "photo_url", "result_url", "id",
895
  ]
896
  writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
897
  writer.writeheader()
898
  for row in rows:
899
  flat = {
900
+ "kol_name": _csv_safe(row.get("kol_name", "")),
901
  "created_at": row.get("created_at", ""),
902
  "mode": row.get("mode", ""),
903
  "ring_model": row.get("ring_model", ""),
 
906
  "overall_range_max": row.get("overall_range_max", ""),
907
  "confidence": row.get("confidence", ""),
908
  "fail_reason": row.get("fail_reason", ""),
909
+ "ai_explanation": _csv_safe((row.get("result_json") or {}).get("ai_explanation", "")),
910
+ "ring_fit": _csv_safe(row.get("ring_fit", "")),
911
+ "gt_best_finger": _csv_safe(row.get("gt_best_finger", "")),
912
  "gt_index_size": row.get("gt_index_size", ""),
913
  "gt_middle_size": row.get("gt_middle_size", ""),
914
  "gt_ring_size": row.get("gt_ring_size", ""),
915
+ "gt_notes": _csv_safe(row.get("gt_notes", "")),
916
+ "feedback_rating": row.get("feedback_rating", ""),
917
+ "feedback_message": _csv_safe(row.get("feedback_message", "")),
918
  "photo_url": row.get("photo_url", ""),
919
  "result_url": row.get("result_url", ""),
920
  "id": row.get("id", ""),
web_demo/static/app.js CHANGED
@@ -12,7 +12,15 @@ const fingerSelectGroup = document.getElementById("fingerSelectGroup");
12
  const multiResultPanel = document.getElementById("multiResultPanel");
13
  const overallSize = document.getElementById("overallSize");
14
  const fingerBreakdown = document.getElementById("fingerBreakdown");
 
 
 
 
 
 
15
  const defaultSampleUrl = window.DEFAULT_SAMPLE_URL || "";
 
 
16
  const failReasonMessageMap = {
17
  card_not_detected:
18
  "Card not detected. A card of standard credit card dimensions (85.6 Γ— 54 mm) is required as a scale reference to measure your finger diameter. Place the card beside your hand on a plain white background (e.g. a sheet of paper), and turn on your phone's flash.",
@@ -225,6 +233,13 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
225
  renderElapsed();
226
  jsonOutput.textContent = '{\n "status": "processing"\n}';
227
  fingerBreakdown.innerHTML = "";
 
 
 
 
 
 
 
228
  const timerId = setInterval(renderElapsed, 1000);
229
 
230
  try {
@@ -240,6 +255,15 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
240
  }
241
 
242
  const data = await response.json();
 
 
 
 
 
 
 
 
 
243
  jsonOutput.textContent = JSON.stringify(data.result, null, 2);
244
  jsonLink.href = data.result_json_url || "#";
245
 
@@ -321,3 +345,76 @@ if (defaultSampleUrl) {
321
  showImage(inputPreview, inputFrame, defaultSampleUrl);
322
  setStatus("Sample image loaded. Click Start Measurement or upload your own photo.");
323
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  const multiResultPanel = document.getElementById("multiResultPanel");
13
  const overallSize = document.getElementById("overallSize");
14
  const fingerBreakdown = document.getElementById("fingerBreakdown");
15
+ const feedbackSection = document.getElementById("feedbackSection");
16
+ const feedbackForm = document.getElementById("feedbackForm");
17
+ const feedbackMessage = document.getElementById("feedbackMessage");
18
+ const feedbackStatus = document.getElementById("feedbackStatus");
19
+ const feedbackSubmit = feedbackForm ? feedbackForm.querySelector(".feedback-submit") : null;
20
+ const feedbackStarButtons = feedbackForm ? Array.from(feedbackForm.querySelectorAll(".star-btn")) : [];
21
  const defaultSampleUrl = window.DEFAULT_SAMPLE_URL || "";
22
+ let lastRunId = "";
23
+ let feedbackRating = 0;
24
  const failReasonMessageMap = {
25
  card_not_detected:
26
  "Card not detected. A card of standard credit card dimensions (85.6 Γ— 54 mm) is required as a scale reference to measure your finger diameter. Place the card beside your hand on a plain white background (e.g. a sheet of paper), and turn on your phone's flash.",
 
233
  renderElapsed();
234
  jsonOutput.textContent = '{\n "status": "processing"\n}';
235
  fingerBreakdown.innerHTML = "";
236
+ // Clear feedback state at the top so every code path β€” HTTP error,
237
+ // network error, fail_reason result β€” starts from a hidden panel and
238
+ // an empty run_id. Otherwise a successful run that's followed by an
239
+ // HTTP / network error would leave the panel visible with the prior
240
+ // run_id, and any feedback would attach to the wrong row.
241
+ lastRunId = "";
242
+ if (feedbackSection) feedbackSection.hidden = true;
243
  const timerId = setInterval(renderElapsed, 1000);
244
 
245
  try {
 
255
  }
256
 
257
  const data = await response.json();
258
+ lastRunId = data.run_id || "";
259
+ if (feedbackSection) {
260
+ if (data.success && lastRunId) {
261
+ feedbackSection.hidden = false;
262
+ resetFeedbackForm();
263
+ } else {
264
+ feedbackSection.hidden = true;
265
+ }
266
+ }
267
  jsonOutput.textContent = JSON.stringify(data.result, null, 2);
268
  jsonLink.href = data.result_json_url || "#";
269
 
 
345
  showImage(inputPreview, inputFrame, defaultSampleUrl);
346
  setStatus("Sample image loaded. Click Start Measurement or upload your own photo.");
347
  }
348
+
349
+ function paintStars() {
350
+ feedbackStarButtons.forEach((btn) => {
351
+ const v = Number(btn.dataset.value);
352
+ const filled = v <= feedbackRating;
353
+ btn.classList.toggle("star-filled", filled);
354
+ btn.setAttribute("aria-checked", v === feedbackRating ? "true" : "false");
355
+ });
356
+ }
357
+
358
+ function resetFeedbackForm() {
359
+ feedbackRating = 0;
360
+ feedbackMessage.value = "";
361
+ feedbackStatus.textContent = "";
362
+ feedbackStatus.className = "feedback-status";
363
+ paintStars();
364
+ }
365
+
366
+ if (feedbackForm) {
367
+ feedbackStarButtons.forEach((btn) => {
368
+ btn.addEventListener("click", () => {
369
+ // Set-only (no toggle-to-clear) β€” the previous "click same star
370
+ // to clear" behavior was undiscoverable and made it easy to wipe
371
+ // a rating with a stray double-tap. Users can change the rating
372
+ // by clicking a different star.
373
+ feedbackRating = Number(btn.dataset.value);
374
+ paintStars();
375
+ });
376
+ });
377
+
378
+ feedbackForm.addEventListener("submit", async (event) => {
379
+ event.preventDefault();
380
+ const message = (feedbackMessage.value || "").trim();
381
+ if (!feedbackRating && !message) {
382
+ feedbackStatus.textContent = "Pick a rating or write a comment.";
383
+ feedbackStatus.className = "feedback-status feedback-status-error";
384
+ return;
385
+ }
386
+ if (!lastRunId) {
387
+ feedbackStatus.textContent = "No measurement to attach this to yet.";
388
+ feedbackStatus.className = "feedback-status feedback-status-error";
389
+ return;
390
+ }
391
+ feedbackSubmit.disabled = true;
392
+ feedbackStatus.textContent = "Sending…";
393
+ feedbackStatus.className = "feedback-status";
394
+ try {
395
+ const resp = await fetch("/api/feedback", {
396
+ method: "POST",
397
+ headers: { "Content-Type": "application/json" },
398
+ body: JSON.stringify({
399
+ run_id: lastRunId,
400
+ rating: feedbackRating || undefined,
401
+ message,
402
+ }),
403
+ });
404
+ if (!resp.ok) {
405
+ const err = await resp.json().catch(() => ({}));
406
+ throw new Error(err.error || `HTTP ${resp.status}`);
407
+ }
408
+ feedbackStatus.textContent = "Thanks β€” sent.";
409
+ feedbackStatus.className = "feedback-status feedback-status-ok";
410
+ feedbackMessage.value = "";
411
+ feedbackRating = 0;
412
+ paintStars();
413
+ } catch (err) {
414
+ feedbackStatus.textContent = `Couldn't send: ${err.message}`;
415
+ feedbackStatus.className = "feedback-status feedback-status-error";
416
+ } finally {
417
+ feedbackSubmit.disabled = false;
418
+ }
419
+ });
420
+ }
web_demo/static/mobile/mobile.css CHANGED
@@ -824,3 +824,72 @@ body {
824
  .size-ref-table tbody tr:nth-child(even) {
825
  background: rgba(245, 241, 231, 0.5);
826
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824
  .size-ref-table tbody tr:nth-child(even) {
825
  background: rgba(245, 241, 231, 0.5);
826
  }
827
+
828
+ /* --- Feedback panel (mirrors desktop .feedback-*) ---------------- */
829
+
830
+ .feedback-panel .feedback-hint {
831
+ margin: 0 0 12px;
832
+ color: var(--ink-soft);
833
+ font-size: 0.95rem;
834
+ }
835
+
836
+ .feedback-form {
837
+ display: flex;
838
+ flex-direction: column;
839
+ gap: 10px;
840
+ }
841
+
842
+ .feedback-rating {
843
+ display: flex;
844
+ gap: 6px;
845
+ }
846
+
847
+ .star-btn {
848
+ background: none;
849
+ border: none;
850
+ padding: 4px 6px;
851
+ font-size: 2rem;
852
+ line-height: 1;
853
+ color: rgba(45, 33, 33, 0.25);
854
+ cursor: pointer;
855
+ transition: color 0.15s ease, transform 0.1s ease;
856
+ }
857
+
858
+ .star-btn.star-filled { color: #e3a73b; }
859
+
860
+ .feedback-form textarea {
861
+ width: 100%;
862
+ border: 1px solid var(--border);
863
+ border-radius: 12px;
864
+ padding: 10px 12px;
865
+ /* 1rem keeps iOS Safari from auto-zooming the page on focus
866
+ (same reason the .controls inputs use 1rem). */
867
+ font-size: 1rem;
868
+ font-family: inherit;
869
+ background: white;
870
+ color: var(--ink);
871
+ resize: vertical;
872
+ min-height: 80px;
873
+ }
874
+
875
+ .feedback-row {
876
+ display: flex;
877
+ align-items: center;
878
+ gap: 12px;
879
+ flex-wrap: wrap;
880
+ flex-direction: row-reverse;
881
+ justify-content: flex-start;
882
+ }
883
+
884
+ .feedback-submit {
885
+ width: auto;
886
+ padding: 10px 22px;
887
+ }
888
+
889
+ .feedback-status {
890
+ font-size: 0.9rem;
891
+ color: var(--ink-soft);
892
+ }
893
+
894
+ .feedback-status-ok { color: #2f7a3d; }
895
+ .feedback-status-error { color: var(--accent); font-weight: 600; }
web_demo/static/mobile/steps/result.js CHANGED
@@ -8,6 +8,7 @@
8
 
9
  import { session, resetForRetake } from "../session.js";
10
  import { formatFailReason } from "../../shared/fail-reasons.js";
 
11
 
12
  const FINGER_LABEL = {
13
  index: "Index Finger",
@@ -143,6 +144,31 @@ export default {
143
  ? `<img src="${escape(overlayUrl)}" alt="Measurement overlay"
144
  onerror="this.remove()" />`
145
  : "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  container.innerHTML = `
147
  <section class="step step-result">
148
  <div class="step-body">
@@ -156,6 +182,8 @@ export default {
156
  <h2 class="panel-title">Ring Size Recommendation</h2>
157
  ${renderRecommendation(payload, ringModel)}
158
  </div>
 
 
159
  </div>
160
  <footer class="step-foot">
161
  <button type="button" class="primary step-retake">Measure Again</button>
@@ -163,6 +191,66 @@ export default {
163
  </section>
164
  `;
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  // "Measure again" returns to the photo-guide step so the user can
167
  // pick between Open Camera and Upload from photos β€” the upload
168
  // entry was added after this button originally jumped straight to
 
8
 
9
  import { session, resetForRetake } from "../session.js";
10
  import { formatFailReason } from "../../shared/fail-reasons.js";
11
+ import { submitFeedback } from "../../shared/feedback.js";
12
 
13
  const FINGER_LABEL = {
14
  index: "Index Finger",
 
144
  ? `<img src="${escape(overlayUrl)}" alt="Measurement overlay"
145
  onerror="this.remove()" />`
146
  : "";
147
+ // Only invite feedback after a successful run β€” asking "how did it
148
+ // go?" on top of an error reads as tone-deaf, and ratings attached
149
+ // to failed rows would skew dashboard stats.
150
+ const feedbackPanel = succeeded && data.run_id
151
+ ? `
152
+ <div class="panel feedback-panel">
153
+ <h2 class="panel-title">How did it go?</h2>
154
+ <p class="feedback-hint">Rate this measurement and tell us anything you'd like to share or ask.</p>
155
+ <form class="feedback-form" id="mobileFeedbackForm">
156
+ <div class="feedback-rating" role="radiogroup" aria-label="Rating">
157
+ ${[1, 2, 3, 4, 5].map((v) => `
158
+ <button type="button" class="star-btn" data-value="${v}" role="radio" aria-checked="false" aria-label="${v} star${v > 1 ? "s" : ""}">β˜…</button>
159
+ `).join("")}
160
+ </div>
161
+ <textarea class="feedback-message" name="message" rows="4" maxlength="4000"
162
+ placeholder="Your comment or question (optional)"></textarea>
163
+ <div class="feedback-row">
164
+ <button type="submit" class="primary feedback-submit">Send</button>
165
+ <span class="feedback-status" aria-live="polite"></span>
166
+ </div>
167
+ </form>
168
+ </div>
169
+ `
170
+ : "";
171
+
172
  container.innerHTML = `
173
  <section class="step step-result">
174
  <div class="step-body">
 
182
  <h2 class="panel-title">Ring Size Recommendation</h2>
183
  ${renderRecommendation(payload, ringModel)}
184
  </div>
185
+
186
+ ${feedbackPanel}
187
  </div>
188
  <footer class="step-foot">
189
  <button type="button" class="primary step-retake">Measure Again</button>
 
191
  </section>
192
  `;
193
 
194
+ const feedbackForm = container.querySelector("#mobileFeedbackForm");
195
+ if (feedbackForm) {
196
+ const messageEl = feedbackForm.querySelector(".feedback-message");
197
+ const statusEl = feedbackForm.querySelector(".feedback-status");
198
+ const submitEl = feedbackForm.querySelector(".feedback-submit");
199
+ const starButtons = Array.from(feedbackForm.querySelectorAll(".star-btn"));
200
+ let rating = 0;
201
+ const paintStars = () => {
202
+ starButtons.forEach((btn) => {
203
+ const v = Number(btn.dataset.value);
204
+ const filled = v <= rating;
205
+ btn.classList.toggle("star-filled", filled);
206
+ btn.setAttribute("aria-checked", v === rating ? "true" : "false");
207
+ });
208
+ };
209
+ starButtons.forEach((btn) => {
210
+ btn.addEventListener("click", () => {
211
+ // Set-only (no toggle-to-clear) β€” see desktop app.js for
212
+ // the rationale; the previous behavior made it easy to wipe
213
+ // a rating with an accidental double-tap.
214
+ rating = Number(btn.dataset.value);
215
+ paintStars();
216
+ });
217
+ });
218
+
219
+ feedbackForm.addEventListener("submit", async (event) => {
220
+ event.preventDefault();
221
+ const message = (messageEl.value || "").trim();
222
+ if (!rating && !message) {
223
+ statusEl.textContent = "Pick a rating or write a comment.";
224
+ statusEl.className = "feedback-status feedback-status-error";
225
+ return;
226
+ }
227
+ if (!data.run_id) {
228
+ statusEl.textContent = "No measurement to attach this to.";
229
+ statusEl.className = "feedback-status feedback-status-error";
230
+ return;
231
+ }
232
+ submitEl.disabled = true;
233
+ statusEl.textContent = "Sending…";
234
+ statusEl.className = "feedback-status";
235
+ const res = await submitFeedback({
236
+ runId: data.run_id,
237
+ rating: rating || undefined,
238
+ message,
239
+ });
240
+ if (res.success) {
241
+ statusEl.textContent = "Thanks β€” sent.";
242
+ statusEl.className = "feedback-status feedback-status-ok";
243
+ messageEl.value = "";
244
+ rating = 0;
245
+ paintStars();
246
+ } else {
247
+ statusEl.textContent = `Couldn't send: ${res.error || "try again"}`;
248
+ statusEl.className = "feedback-status feedback-status-error";
249
+ }
250
+ submitEl.disabled = false;
251
+ });
252
+ }
253
+
254
  // "Measure again" returns to the photo-guide step so the user can
255
  // pick between Open Camera and Upload from photos β€” the upload
256
  // entry was added after this button originally jumped straight to
web_demo/static/shared/feedback.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // POST feedback (rating + comment) attached to a specific measurement
2
+ // run. The endpoint requires a run_id and either a rating or a message
3
+ // (or both). Returns { success, error? } β€” the server returns 200 even
4
+ // when persistence is offline so callers only need to handle network
5
+ // errors and 4xx validation failures.
6
+ export async function submitFeedback({ runId, rating, message }) {
7
+ if (!runId) {
8
+ return { success: false, error: "Missing run_id" };
9
+ }
10
+ const body = { run_id: runId };
11
+ if (rating) body.rating = rating;
12
+ if (message) body.message = message;
13
+ const resp = await fetch("/api/feedback", {
14
+ method: "POST",
15
+ headers: { "Content-Type": "application/json" },
16
+ body: JSON.stringify(body),
17
+ });
18
+ let data = null;
19
+ try {
20
+ data = await resp.json();
21
+ } catch {
22
+ /* ignore parse errors β€” fall back to status code */
23
+ }
24
+ if (!resp.ok) {
25
+ return { success: false, error: (data && data.error) || `HTTP ${resp.status}` };
26
+ }
27
+ return { success: true };
28
+ }
web_demo/static/styles.css CHANGED
@@ -325,6 +325,79 @@ pre {
325
  }
326
  }
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  /* Multi-finger result styles */
329
  .multi-result {
330
  padding: 8px 0;
 
325
  }
326
  }
327
 
328
+ /* Feedback panel β€” sits inside .content, so it inherits the 8vw
329
+ horizontal padding from there. No extra padding here, otherwise the
330
+ panel ends up narrower than its sibling result panels above it. */
331
+ .feedback-section {
332
+ position: relative;
333
+ z-index: 1;
334
+ }
335
+
336
+ .feedback-hint {
337
+ margin: 0 0 12px;
338
+ color: var(--ink-soft);
339
+ font-size: 0.95rem;
340
+ }
341
+
342
+ .feedback-form {
343
+ display: flex;
344
+ flex-direction: column;
345
+ gap: 10px;
346
+ }
347
+
348
+ .feedback-rating {
349
+ display: flex;
350
+ gap: 4px;
351
+ }
352
+
353
+ .star-btn {
354
+ background: none;
355
+ border: none;
356
+ padding: 2px 4px;
357
+ font-size: 1.7rem;
358
+ line-height: 1;
359
+ color: rgba(45, 33, 33, 0.25);
360
+ cursor: pointer;
361
+ transition: color 0.15s ease, transform 0.1s ease;
362
+ }
363
+
364
+ .star-btn:hover { transform: translateY(-1px); }
365
+ .star-btn.star-filled { color: #e3a73b; }
366
+
367
+ .feedback-form textarea {
368
+ width: 100%;
369
+ border: 1px solid var(--border);
370
+ border-radius: 12px;
371
+ padding: 10px 12px;
372
+ font-size: 1rem;
373
+ font-family: inherit;
374
+ background: white;
375
+ color: var(--ink);
376
+ resize: vertical;
377
+ min-height: 80px;
378
+ }
379
+
380
+ .feedback-row {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 12px;
384
+ flex-direction: row-reverse;
385
+ justify-content: flex-start;
386
+ }
387
+
388
+ .feedback-submit {
389
+ width: auto;
390
+ padding: 10px 22px;
391
+ }
392
+
393
+ .feedback-status {
394
+ font-size: 0.9rem;
395
+ color: var(--ink-soft);
396
+ }
397
+
398
+ .feedback-status-ok { color: #2f7a3d; }
399
+ .feedback-status-error { color: var(--accent); font-weight: 600; }
400
+
401
  /* Multi-finger result styles */
402
  .multi-result {
403
  padding: 8px 0;
web_demo/supabase_client.py CHANGED
@@ -123,7 +123,7 @@ def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]
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"
127
  )
128
 
129
 
@@ -151,6 +151,41 @@ def list_measurements_for_stats(limit: int = 5000) -> List[Dict[str, Any]]:
151
  return []
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def delete_measurement(measurement_id: str) -> bool:
155
  """Delete a measurement record by ID."""
156
  client = _get_client()
 
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
  )
128
 
129
 
 
151
  return []
152
 
153
 
154
+ FEEDBACK_ALLOWED_FIELDS = {"feedback_rating", "feedback_message"}
155
+
156
+ # Return values for update_measurement_feedback. Stringly-typed because the
157
+ # caller (the /api/feedback endpoint) needs to distinguish "persistence is
158
+ # turned off in this env" (still report success to the user) from "the row
159
+ # really doesn't exist" (surface a real error).
160
+ FEEDBACK_OK = "ok"
161
+ FEEDBACK_NO_ROW = "no_row"
162
+ FEEDBACK_DISABLED = "disabled"
163
+ FEEDBACK_ERROR = "error"
164
+
165
+
166
+ def update_measurement_feedback(run_id: str, updates: Dict[str, Any]) -> str:
167
+ """Attach feedback (rating + message) to an existing measurement row.
168
+
169
+ Looks up the row by `run_id` (stamped at measure-time by app.py) and
170
+ patches only the whitelisted feedback columns. Pass only the columns
171
+ you actually want to change β€” keys not present are left untouched.
172
+ Returns one of FEEDBACK_OK / FEEDBACK_NO_ROW / FEEDBACK_DISABLED /
173
+ FEEDBACK_ERROR so the caller can decide whether to surface a failure.
174
+ """
175
+ client = _get_client()
176
+ if client is None:
177
+ return FEEDBACK_DISABLED
178
+ safe = {k: v for k, v in updates.items() if k in FEEDBACK_ALLOWED_FIELDS}
179
+ if not safe or not run_id:
180
+ return FEEDBACK_NO_ROW
181
+ try:
182
+ resp = client.table("measurements").update(safe).eq("run_id", run_id).execute()
183
+ return FEEDBACK_OK if resp.data else FEEDBACK_NO_ROW
184
+ except Exception as e:
185
+ logger.error("Failed to update feedback for run %s: %s", run_id, e)
186
+ return FEEDBACK_ERROR
187
+
188
+
189
  def delete_measurement(measurement_id: str) -> bool:
190
  """Delete a measurement record by ID."""
191
  client = _get_client()
web_demo/templates/admin.html CHANGED
@@ -76,6 +76,14 @@
76
  .finger-cell { font-size: 12px; }
77
  .finger-cell .size { font-weight: 600; }
78
  .finger-cell .detail { color: var(--ink-soft); }
 
 
 
 
 
 
 
 
79
  .empty { text-align: center; padding: 48px; color: var(--ink-soft); }
80
  .scroll-wrap { overflow-x: auto; }
81
  /* --- Tabs --- */
@@ -165,12 +173,49 @@
165
  <div class="admin-content" id="adminContent">
166
  <h1>Ring Sizer β€” Admin</h1>
167
  <div class="tabs">
168
- <button class="tab active" data-pane="dashboardPane">Dashboard</button>
169
- <button class="tab" data-pane="recordsPane">Records</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  </div>
171
 
172
  <!-- Dashboard pane -->
173
- <div class="pane active" id="dashboardPane">
174
  <div class="toolbar">
175
  <a href="/">Back to Demo</a>
176
  <button id="dashRefreshBtn">Refresh</button>
@@ -218,40 +263,10 @@
218
  <h2>Mode (single vs multi)</h2>
219
  <div class="chart-wrap"><canvas id="chartModes"></canvas></div>
220
  </div>
221
- </div>
222
- </div>
223
-
224
- <!-- Records pane -->
225
- <div class="pane" id="recordsPane">
226
- <div class="toolbar">
227
- <a href="/">Back to Demo</a>
228
- <a id="exportCsvLink" href="#" download>Export CSV</a>
229
- <button id="refreshBtn">Refresh</button>
230
- <button id="prevPageBtn" disabled>&lsaquo; Prev</button>
231
- <button id="nextPageBtn" disabled>Next &rsaquo;</button>
232
- <span class="count" id="countLabel">Loading...</span>
233
- </div>
234
-
235
- <div class="scroll-wrap">
236
- <table>
237
- <thead>
238
- <tr>
239
- <th>KOL</th>
240
- <th>Date</th>
241
- <th>Model</th>
242
- <th>Photo</th>
243
- <th>Index</th>
244
- <th>Middle</th>
245
- <th>Ring</th>
246
- <th>Conf</th>
247
- <th>Fail</th>
248
- <th></th>
249
- </tr>
250
- </thead>
251
- <tbody id="tableBody">
252
- <tr><td colspan="10" class="empty">Loading...</td></tr>
253
- </tbody>
254
- </table>
255
  </div>
256
  </div>
257
  </div>
@@ -321,21 +336,43 @@
321
  return `<span class="size">${size}</span> <span class="detail">${diam} ${conf}</span>`;
322
  };
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  const rowHtml = (r) => {
325
  const pf = r.per_finger || {};
326
  const photoThumb = r.photo_url
327
  ? `<img class="thumb" loading="lazy" src="${r.photo_url}" onclick="window.open('${r.photo_url}')" />`
328
  : "-";
 
 
 
329
  return `<tr data-id="${r.id}">
330
  <td><strong>${r.kol_name || "-"}</strong></td>
331
  <td>${fmtDate(r.created_at)}</td>
332
  <td>${r.ring_model || "-"}</td>
333
  <td>${photoThumb}</td>
 
334
  <td class="finger-cell">${fmtFinger(pf, "index")}</td>
335
  <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
336
  <td class="finger-cell">${fmtFinger(pf, "ring")}</td>
337
  <td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
338
  <td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
 
 
339
  <td><button class="del-btn" onclick="deleteRow(this)">Delete</button></td>
340
  </tr>`;
341
  };
@@ -347,7 +384,7 @@
347
  if (currentPage < 1) currentPage = 1;
348
  if (total === 0) {
349
  countLabel.textContent = "0 records";
350
- tbody.innerHTML = '<tr><td colspan="10" class="empty">No measurements yet</td></tr>';
351
  } else {
352
  const start = (currentPage - 1) * PAGE_SIZE;
353
  const slice = allRows.slice(start, start + PAGE_SIZE);
@@ -372,7 +409,7 @@
372
  currentPage = 1;
373
  renderPage();
374
  } catch (e) {
375
- tbody.innerHTML = `<tr><td colspan="10" class="empty">Error loading data: ${e.message}</td></tr>`;
376
  }
377
  };
378
 
@@ -449,7 +486,11 @@
449
  { label: "Last 7 days", value: fmtInt(t.last_7_days), sub: last7Delta.txt, subCls: last7Delta.cls },
450
  { label: "Success rate", value: fmtPct(t.success_rate), sub: `${fmtInt(t.success_count)} ok Β· ${fmtInt(t.fail_count)} failed` },
451
  { label: "Avg confidence", value: fmtPct(t.avg_confidence), sub: "successful runs only" },
452
- { label: "Ground-truth coverage", value: fmtPct(t.gt_filled_rate), sub: `${fmtInt(t.gt_filled_count)} records labeled` },
 
 
 
 
453
  ];
454
  statGrid.innerHTML = cards.map((c) => `
455
  <div class="stat-card">
@@ -484,16 +525,16 @@
484
  data: {
485
  labels,
486
  datasets: [
487
- { label: "Photos", data: s.per_day.map((d) => d.photos), borderColor: ACCENT, backgroundColor: ACCENT + "33", tension: 0.25, fill: true },
488
- { label: "Unique KOLs", data: s.per_day.map((d) => d.unique_kols), borderColor: "#3d6b8b", backgroundColor: "transparent", tension: 0.25 },
489
- { label: "Failures", data: s.per_day.map((d) => d.fails), borderColor: "#a05a3c", backgroundColor: "transparent", borderDash: [4, 4], tension: 0.25 },
490
  ],
491
  },
492
  options: {
493
  responsive: true, maintainAspectRatio: false,
494
  interaction: { mode: "index", intersect: false },
495
  scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
496
- plugins: { legend: { position: "bottom" } },
497
  },
498
  });
499
 
@@ -567,6 +608,24 @@
567
  plugins: { legend: { position: "right" } },
568
  },
569
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  };
571
 
572
  const loadStats = async () => {
 
76
  .finger-cell { font-size: 12px; }
77
  .finger-cell .size { font-weight: 600; }
78
  .finger-cell .detail { color: var(--ink-soft); }
79
+ .rating-stars { color: #e3a73b; letter-spacing: 1px; font-size: 13px; }
80
+ .rating-stars .rating-empty { color: rgba(45, 33, 33, 0.18); }
81
+ .comment-cell {
82
+ display: inline-block; max-width: 220px;
83
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
84
+ vertical-align: middle; color: var(--ink-soft); font-size: 12px;
85
+ cursor: help;
86
+ }
87
  .empty { text-align: center; padding: 48px; color: var(--ink-soft); }
88
  .scroll-wrap { overflow-x: auto; }
89
  /* --- Tabs --- */
 
173
  <div class="admin-content" id="adminContent">
174
  <h1>Ring Sizer β€” Admin</h1>
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 -->
181
+ <div class="pane active" id="recordsPane">
182
+ <div class="toolbar">
183
+ <a href="/">Back to Demo</a>
184
+ <a id="exportCsvLink" href="#" download>Export CSV</a>
185
+ <button id="refreshBtn">Refresh</button>
186
+ <button id="prevPageBtn" disabled>&lsaquo; Prev</button>
187
+ <button id="nextPageBtn" disabled>Next &rsaquo;</button>
188
+ <span class="count" id="countLabel">Loading...</span>
189
+ </div>
190
+
191
+ <div class="scroll-wrap">
192
+ <table>
193
+ <thead>
194
+ <tr>
195
+ <th>KOL</th>
196
+ <th>Date</th>
197
+ <th>Model</th>
198
+ <th>Photo</th>
199
+ <th>Result</th>
200
+ <th>Index</th>
201
+ <th>Middle</th>
202
+ <th>Ring</th>
203
+ <th>Conf</th>
204
+ <th>Fail</th>
205
+ <th>Rating</th>
206
+ <th>Comment</th>
207
+ <th></th>
208
+ </tr>
209
+ </thead>
210
+ <tbody id="tableBody">
211
+ <tr><td colspan="13" class="empty">Loading...</td></tr>
212
+ </tbody>
213
+ </table>
214
+ </div>
215
  </div>
216
 
217
  <!-- Dashboard pane -->
218
+ <div class="pane" id="dashboardPane">
219
  <div class="toolbar">
220
  <a href="/">Back to Demo</a>
221
  <button id="dashRefreshBtn">Refresh</button>
 
263
  <h2>Mode (single vs multi)</h2>
264
  <div class="chart-wrap"><canvas id="chartModes"></canvas></div>
265
  </div>
266
+ <div class="chart-card">
267
+ <h2>User Ratings</h2>
268
+ <div class="chart-wrap"><canvas id="chartRatings"></canvas></div>
269
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  </div>
271
  </div>
272
  </div>
 
336
  return `<span class="size">${size}</span> <span class="detail">${diam} ${conf}</span>`;
337
  };
338
 
339
+ const esc = (s) => String(s == null ? "" : s)
340
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
341
+ .replace(/"/g, "&quot;");
342
+
343
+ const fmtRating = (r) => {
344
+ if (r == null || r < 1 || r > 5) return '<span class="detail">β€”</span>';
345
+ return `<span class="rating-stars" title="${r}/5">${"β˜…".repeat(r)}<span class="rating-empty">${"β˜…".repeat(5 - r)}</span></span>`;
346
+ };
347
+
348
+ const fmtComment = (msg) => {
349
+ if (!msg) return '<span class="detail">β€”</span>';
350
+ const trimmed = String(msg).slice(0, 80);
351
+ const more = msg.length > 80 ? "…" : "";
352
+ return `<span class="comment-cell" title="${esc(msg)}">${esc(trimmed)}${more}</span>`;
353
+ };
354
+
355
  const rowHtml = (r) => {
356
  const pf = r.per_finger || {};
357
  const photoThumb = r.photo_url
358
  ? `<img class="thumb" loading="lazy" src="${r.photo_url}" onclick="window.open('${r.photo_url}')" />`
359
  : "-";
360
+ const resultThumb = r.result_url
361
+ ? `<img class="thumb" loading="lazy" src="${r.result_url}" onclick="window.open('${r.result_url}')" />`
362
+ : "-";
363
  return `<tr data-id="${r.id}">
364
  <td><strong>${r.kol_name || "-"}</strong></td>
365
  <td>${fmtDate(r.created_at)}</td>
366
  <td>${r.ring_model || "-"}</td>
367
  <td>${photoThumb}</td>
368
+ <td>${resultThumb}</td>
369
  <td class="finger-cell">${fmtFinger(pf, "index")}</td>
370
  <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
371
  <td class="finger-cell">${fmtFinger(pf, "ring")}</td>
372
  <td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
373
  <td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
374
+ <td>${fmtRating(r.feedback_rating)}</td>
375
+ <td>${fmtComment(r.feedback_message)}</td>
376
  <td><button class="del-btn" onclick="deleteRow(this)">Delete</button></td>
377
  </tr>`;
378
  };
 
384
  if (currentPage < 1) currentPage = 1;
385
  if (total === 0) {
386
  countLabel.textContent = "0 records";
387
+ tbody.innerHTML = '<tr><td colspan="13" class="empty">No measurements yet</td></tr>';
388
  } else {
389
  const start = (currentPage - 1) * PAGE_SIZE;
390
  const slice = allRows.slice(start, start + PAGE_SIZE);
 
409
  currentPage = 1;
410
  renderPage();
411
  } catch (e) {
412
+ tbody.innerHTML = `<tr><td colspan="13" class="empty">Error loading data: ${e.message}</td></tr>`;
413
  }
414
  };
415
 
 
486
  { label: "Last 7 days", value: fmtInt(t.last_7_days), sub: last7Delta.txt, subCls: last7Delta.cls },
487
  { label: "Success rate", value: fmtPct(t.success_rate), sub: `${fmtInt(t.success_count)} ok Β· ${fmtInt(t.fail_count)} failed` },
488
  { label: "Avg confidence", value: fmtPct(t.avg_confidence), sub: "successful runs only" },
489
+ {
490
+ label: "Avg rating",
491
+ value: t.rating_count ? `${t.avg_rating.toFixed(2)} β˜…` : "β€”",
492
+ sub: `${fmtInt(t.rating_count)} rated Β· ${fmtInt(t.comment_count)} comments`,
493
+ },
494
  ];
495
  statGrid.innerHTML = cards.map((c) => `
496
  <div class="stat-card">
 
525
  data: {
526
  labels,
527
  datasets: [
528
+ { label: "Photos", data: s.per_day.map((d) => d.photos), borderColor: ACCENT, backgroundColor: ACCENT + "33", tension: 0.25, fill: true, pointStyle: "line" },
529
+ { label: "Unique KOLs", data: s.per_day.map((d) => d.unique_kols), borderColor: "#3d6b8b", backgroundColor: "transparent", tension: 0.25, pointStyle: "line" },
530
+ { label: "Failures", data: s.per_day.map((d) => d.fails), borderColor: "#a05a3c", backgroundColor: "transparent", borderDash: [4, 4], tension: 0.25, pointStyle: "line" },
531
  ],
532
  },
533
  options: {
534
  responsive: true, maintainAspectRatio: false,
535
  interaction: { mode: "index", intersect: false },
536
  scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
537
+ plugins: { legend: { position: "bottom", labels: { usePointStyle: true } } },
538
  },
539
  });
540
 
 
608
  plugins: { legend: { position: "right" } },
609
  },
610
  });
611
+
612
+ // User ratings β€” vertical bar, 1β˜… β†’ 5β˜…
613
+ const ratings = s.rating_distribution || [];
614
+ upsertChart("ratings", document.getElementById("chartRatings"), {
615
+ type: "bar",
616
+ data: {
617
+ labels: ratings.map((d) => `${d.stars}β˜…`),
618
+ datasets: [{
619
+ data: ratings.map((d) => d.count),
620
+ backgroundColor: ["#bf3a2b", "#d96b2b", "#d9a83b", "#7a8b3d", "#2f7a3d"],
621
+ }],
622
+ },
623
+ options: {
624
+ responsive: true, maintainAspectRatio: false,
625
+ plugins: { legend: { display: false } },
626
+ scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
627
+ },
628
+ });
629
  };
630
 
631
  const loadStats = async () => {
web_demo/templates/index.html CHANGED
@@ -129,6 +129,28 @@
129
  <a id="jsonLink" hidden href="#"></a>
130
  {% endif %}
131
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  </main>
133
 
134
  <footer style="text-align:center;padding:24px 0 12px;opacity:0.4;font-size:13px;">
 
129
  <a id="jsonLink" hidden href="#"></a>
130
  {% endif %}
131
  </section>
132
+
133
+ <section class="feedback-section" id="feedbackSection" hidden>
134
+ <div class="panel feedback-panel" id="feedbackPanel">
135
+ <h2>How did it go?</h2>
136
+ <p class="feedback-hint">Rate this measurement and tell us anything you'd like to share or ask.</p>
137
+ <form id="feedbackForm" class="feedback-form">
138
+ <div class="feedback-rating" role="radiogroup" aria-label="Rating">
139
+ <button type="button" class="star-btn" data-value="1" role="radio" aria-checked="false" aria-label="1 star">β˜…</button>
140
+ <button type="button" class="star-btn" data-value="2" role="radio" aria-checked="false" aria-label="2 stars">β˜…</button>
141
+ <button type="button" class="star-btn" data-value="3" role="radio" aria-checked="false" aria-label="3 stars">β˜…</button>
142
+ <button type="button" class="star-btn" data-value="4" role="radio" aria-checked="false" aria-label="4 stars">β˜…</button>
143
+ <button type="button" class="star-btn" data-value="5" role="radio" aria-checked="false" aria-label="5 stars">β˜…</button>
144
+ </div>
145
+ <textarea id="feedbackMessage" name="message" rows="4" maxlength="4000"
146
+ placeholder="Your comment or question (optional)"></textarea>
147
+ <div class="feedback-row">
148
+ <button type="submit" class="primary feedback-submit">Send</button>
149
+ <span class="feedback-status" id="feedbackStatus" aria-live="polite"></span>
150
+ </div>
151
+ </form>
152
+ </div>
153
+ </section>
154
  </main>
155
 
156
  <footer style="text-align:center;padding:24px 0 12px;opacity:0.4;font-size:13px;">