feng-x commited on
Commit
a2c2ee5
·
verified ·
1 Parent(s): 9f6a8e2

Upload folder using huggingface_hub

Browse files
AGENTS.md CHANGED
@@ -253,6 +253,9 @@ for `recommend_ring_size()`; exact midpoint ties prefer the smaller size. Raw
253
  shot fields remain unchanged. The visible result cards prefer the separate
254
  `session_recommendation` object. A single footer below the cards reads
255
  `Based on N measurements. (Please measure at least 3 times for best reliability)`
 
 
 
256
 
257
  Aggregation is partitioned by `handedness + finger + ring_model`; failed
258
  fingers are excluded and exact duplicate images are identified by SHA-256.
 
253
  shot fields remain unchanged. The visible result cards prefer the separate
254
  `session_recommendation` object. A single footer below the cards reads
255
  `Based on N measurements. (Please measure at least 3 times for best reliability)`
256
+ The admin Records table preserves its raw-shot columns and adds a separate
257
+ `Session Recommendation` summary column with an on-demand JSON view; legacy
258
+ rows display `—` in that column.
259
 
260
  Aggregation is partitioned by `handedness + finger + ring_model`; failed
261
  fingers are excluded and exact duplicate images are identified by SHA-256.
CLAUDE.md CHANGED
@@ -253,6 +253,9 @@ for `recommend_ring_size()`; exact midpoint ties prefer the smaller size. Raw
253
  shot fields remain unchanged. The visible result cards prefer the separate
254
  `session_recommendation` object. A single footer below the cards reads
255
  `Based on N measurements. (Please measure at least 3 times for best reliability)`
 
 
 
256
 
257
  Aggregation is partitioned by `handedness + finger + ring_model`; failed
258
  fingers are excluded and exact duplicate images are identified by SHA-256.
 
253
  shot fields remain unchanged. The visible result cards prefer the separate
254
  `session_recommendation` object. A single footer below the cards reads
255
  `Based on N measurements. (Please measure at least 3 times for best reliability)`
256
+ The admin Records table preserves its raw-shot columns and adds a separate
257
+ `Session Recommendation` summary column with an on-demand JSON view; legacy
258
+ rows display `—` in that column.
259
 
260
  Aggregation is partitioned by `handedness + finger + ring_model`; failed
261
  fingers are excluded and exact duplicate images are identified by SHA-256.
tests/test_admin_session.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const test = require("node:test");
4
+ const vm = require("node:vm");
5
+
6
+ const source = fs.readFileSync(
7
+ "web_demo/static/shared/admin-session.js",
8
+ "utf8",
9
+ );
10
+
11
+ function loadHelper() {
12
+ const window = {};
13
+ vm.runInNewContext(source, { window, Array, Object, String });
14
+ return window.AdminSession;
15
+ }
16
+
17
+ test("returns no summary for a legacy record", () => {
18
+ assert.equal(loadHelper().summary({ session_recommendation: null }), null);
19
+ });
20
+
21
+ test("summarizes a complete session recommendation", () => {
22
+ const summary = loadHelper().summary({
23
+ session_id: "12345678-abcd-ef01-2345-6789abcdef01",
24
+ session_attempt_index: 4,
25
+ session_recommendation: {
26
+ overall_best_size: 7,
27
+ handedness: "Right",
28
+ successful_shots: 3,
29
+ per_finger: {
30
+ index: { status: "ok", best_match: 7 },
31
+ middle: { status: "ok", best_match: 8 },
32
+ ring: { status: "ok", best_match: 6 },
33
+ },
34
+ },
35
+ });
36
+
37
+ assert.equal(summary.overallSize, 7);
38
+ assert.equal(summary.handedness, "Right");
39
+ assert.equal(summary.successfulShots, 3);
40
+ assert.equal(summary.attemptIndex, 4);
41
+ assert.equal(summary.shortSessionId, "12345678");
42
+ assert.deepEqual({ ...summary.perFinger }, { index: 7, middle: 8, ring: 6 });
43
+ });
44
+
45
+ test("uses JSON fallbacks and omits unsuccessful fingers", () => {
46
+ const summary = loadHelper().summary({
47
+ session_recommendation: {
48
+ session_id: "fallback-session",
49
+ attempt_index: 2,
50
+ overall_best_size: 8,
51
+ per_finger: {
52
+ index: { status: "ok", best_match: 8 },
53
+ middle: { status: "failed", best_match: null },
54
+ },
55
+ },
56
+ });
57
+
58
+ assert.equal(summary.attemptIndex, 2);
59
+ assert.equal(summary.shortSessionId, "fallback");
60
+ assert.deepEqual({ ...summary.perFinger }, { index: 8 });
61
+ });
web_demo/static/shared/admin-session.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function (root) {
2
+ "use strict";
3
+
4
+ const FINGERS = ["index", "middle", "ring"];
5
+
6
+ function present(value) {
7
+ return value !== null && value !== undefined && value !== "";
8
+ }
9
+
10
+ function summary(row) {
11
+ const recommendation = row && row.session_recommendation;
12
+ if (!recommendation || typeof recommendation !== "object" || Array.isArray(recommendation)) {
13
+ return null;
14
+ }
15
+
16
+ const perFinger = {};
17
+ const storedPerFinger = recommendation.per_finger;
18
+ if (storedPerFinger && typeof storedPerFinger === "object") {
19
+ FINGERS.forEach((finger) => {
20
+ const item = storedPerFinger[finger];
21
+ if (item && item.status === "ok" && present(item.best_match)) {
22
+ perFinger[finger] = item.best_match;
23
+ }
24
+ });
25
+ }
26
+
27
+ const sessionId = present(row.session_id)
28
+ ? String(row.session_id)
29
+ : (present(recommendation.session_id) ? String(recommendation.session_id) : "");
30
+
31
+ return {
32
+ overallSize: present(recommendation.overall_best_size)
33
+ ? recommendation.overall_best_size
34
+ : null,
35
+ handedness: present(recommendation.handedness) ? recommendation.handedness : "",
36
+ successfulShots: present(recommendation.successful_shots)
37
+ ? recommendation.successful_shots
38
+ : null,
39
+ attemptIndex: present(row.session_attempt_index)
40
+ ? row.session_attempt_index
41
+ : (present(recommendation.attempt_index) ? recommendation.attempt_index : null),
42
+ sessionId,
43
+ shortSessionId: sessionId ? sessionId.slice(0, 8) : "",
44
+ perFinger,
45
+ };
46
+ }
47
+
48
+ root.AdminSession = { summary };
49
+ }(window));
web_demo/templates/admin.html CHANGED
@@ -76,6 +76,19 @@
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 {
@@ -153,6 +166,26 @@
153
  .top-kol-list li:last-child { border-bottom: none; }
154
  .top-kol-list .name { font-weight: 500; }
155
  .top-kol-list .count { color: var(--ink-soft); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  </style>
157
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
158
  </head>
@@ -201,6 +234,7 @@
201
  <th>Index</th>
202
  <th>Middle</th>
203
  <th>Ring</th>
 
204
  <th>Conf</th>
205
  <th>Fail</th>
206
  <th>Rating</th>
@@ -209,7 +243,7 @@
209
  </tr>
210
  </thead>
211
  <tbody id="tableBody">
212
- <tr><td colspan="13" class="empty">Loading...</td></tr>
213
  </tbody>
214
  </table>
215
  </div>
@@ -306,6 +340,15 @@
306
  </div>
307
  </div>
308
 
 
 
 
 
 
 
 
 
 
309
  <script>
310
  const loginGate = document.getElementById("loginGate");
311
  const adminContent = document.getElementById("adminContent");
@@ -396,6 +439,32 @@
396
  return `<span class="comment-cell" title="${esc(msg)}">${esc(trimmed)}${more}</span>`;
397
  };
398
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  const rowHtml = (r) => {
400
  const pf = r.per_finger || {};
401
  const photoThumb = r.photo_url
@@ -413,6 +482,7 @@
413
  <td class="finger-cell">${fmtFinger(pf, "index")}</td>
414
  <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
415
  <td class="finger-cell">${fmtFinger(pf, "ring")}</td>
 
416
  <td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
417
  <td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
418
  <td>${fmtRating(r.feedback_rating)}</td>
@@ -428,7 +498,7 @@
428
  if (currentPage < 1) currentPage = 1;
429
  if (total === 0) {
430
  countLabel.textContent = "0 records";
431
- tbody.innerHTML = '<tr><td colspan="13" class="empty">No measurements yet</td></tr>';
432
  } else {
433
  const start = (currentPage - 1) * PAGE_SIZE;
434
  const slice = allRows.slice(start, start + PAGE_SIZE);
@@ -455,10 +525,33 @@
455
  currentPage = 1;
456
  renderPage();
457
  } catch (e) {
458
- tbody.innerHTML = `<tr><td colspan="13" class="empty">Error loading data: ${e.message}</td></tr>`;
459
  }
460
  };
461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  window.deleteRow = async (btn) => {
463
  if (!confirm("Delete this measurement record?")) return;
464
  const tr = btn.closest("tr");
 
76
  .finger-cell { font-size: 12px; }
77
  .finger-cell .size { font-weight: 600; }
78
  .finger-cell .detail { color: var(--ink-soft); }
79
+ .session-cell {
80
+ min-width: 190px; white-space: normal; font-size: 12px;
81
+ line-height: 1.35;
82
+ }
83
+ .session-overall { font-weight: 600; color: var(--ink); }
84
+ .session-fingers { margin-top: 2px; color: var(--ink); }
85
+ .session-meta { margin-top: 2px; color: var(--ink-soft); }
86
+ .session-json-btn {
87
+ margin-top: 5px; padding: 2px 7px; font-size: 11px; cursor: pointer;
88
+ border: 1px solid var(--border); border-radius: 4px;
89
+ background: #fff; color: var(--ink-soft);
90
+ }
91
+ .session-json-btn:hover { background: var(--sand); color: var(--ink); }
92
  .rating-stars { color: #e3a73b; letter-spacing: 1px; font-size: 13px; }
93
  .rating-stars .rating-empty { color: rgba(45, 33, 33, 0.18); }
94
  .comment-cell {
 
166
  .top-kol-list li:last-child { border-bottom: none; }
167
  .top-kol-list .name { font-weight: 500; }
168
  .top-kol-list .count { color: var(--ink-soft); }
169
+ .session-dialog {
170
+ width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 48px);
171
+ border: 1px solid var(--border); border-radius: 10px; padding: 0;
172
+ color: var(--ink); background: #fff; box-shadow: 0 12px 40px rgba(34, 26, 26, 0.25);
173
+ }
174
+ .session-dialog::backdrop { background: rgba(34, 26, 26, 0.45); }
175
+ .session-dialog-head {
176
+ display: flex; align-items: center; justify-content: space-between;
177
+ gap: 16px; padding: 14px 16px; border-bottom: 1px solid var(--border);
178
+ }
179
+ .session-dialog-head h2 { margin: 0; font-size: 15px; }
180
+ .session-dialog-head button {
181
+ padding: 4px 10px; cursor: pointer; border: 1px solid var(--border);
182
+ border-radius: 5px; background: #fff; color: var(--ink);
183
+ }
184
+ .session-dialog pre {
185
+ margin: 0; padding: 16px; overflow: auto; max-height: calc(100vh - 130px);
186
+ white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.45;
187
+ background: #fcfaf6;
188
+ }
189
  </style>
190
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
191
  </head>
 
234
  <th>Index</th>
235
  <th>Middle</th>
236
  <th>Ring</th>
237
+ <th>Session Recommendation</th>
238
  <th>Conf</th>
239
  <th>Fail</th>
240
  <th>Rating</th>
 
243
  </tr>
244
  </thead>
245
  <tbody id="tableBody">
246
+ <tr><td colspan="14" class="empty">Loading...</td></tr>
247
  </tbody>
248
  </table>
249
  </div>
 
340
  </div>
341
  </div>
342
 
343
+ <dialog class="session-dialog" id="sessionJsonDialog">
344
+ <div class="session-dialog-head">
345
+ <h2 id="sessionJsonTitle">Session Recommendation JSON</h2>
346
+ <button type="button" onclick="closeSessionJson()">Close</button>
347
+ </div>
348
+ <pre id="sessionJsonOutput"></pre>
349
+ </dialog>
350
+
351
+ <script src="/static/shared/admin-session.js"></script>
352
  <script>
353
  const loginGate = document.getElementById("loginGate");
354
  const adminContent = document.getElementById("adminContent");
 
439
  return `<span class="comment-cell" title="${esc(msg)}">${esc(trimmed)}${more}</span>`;
440
  };
441
 
442
+ const fmtSessionRecommendation = (row) => {
443
+ const session = window.AdminSession.summary(row);
444
+ if (!session) return '<span class="detail">—</span>';
445
+
446
+ const fingerLabels = { index: "I", middle: "M", ring: "R" };
447
+ const fingers = Object.entries(session.perFinger)
448
+ .map(([finger, size]) => `${fingerLabels[finger]} ${esc(size)}`)
449
+ .join(" · ");
450
+ const evidence = [];
451
+ if (session.handedness) evidence.push(esc(session.handedness));
452
+ if (session.successfulShots != null) {
453
+ const noun = Number(session.successfulShots) === 1 ? "shot" : "shots";
454
+ evidence.push(`${esc(session.successfulShots)} successful ${noun}`);
455
+ }
456
+ const audit = [];
457
+ if (session.attemptIndex != null) audit.push(`Attempt ${esc(session.attemptIndex)}`);
458
+ if (session.shortSessionId) audit.push(esc(session.shortSessionId));
459
+
460
+ return `
461
+ <div class="session-overall">Overall ${session.overallSize != null ? esc(session.overallSize) : "—"}</div>
462
+ ${fingers ? `<div class="session-fingers">${fingers}</div>` : ""}
463
+ ${evidence.length ? `<div class="session-meta">${evidence.join(" · ")}</div>` : ""}
464
+ ${audit.length ? `<div class="session-meta">${audit.join(" · ")}</div>` : ""}
465
+ <button class="session-json-btn" type="button" onclick="viewSessionJson(this)">View JSON</button>`;
466
+ };
467
+
468
  const rowHtml = (r) => {
469
  const pf = r.per_finger || {};
470
  const photoThumb = r.photo_url
 
482
  <td class="finger-cell">${fmtFinger(pf, "index")}</td>
483
  <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
484
  <td class="finger-cell">${fmtFinger(pf, "ring")}</td>
485
+ <td class="session-cell">${fmtSessionRecommendation(r)}</td>
486
  <td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
487
  <td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
488
  <td>${fmtRating(r.feedback_rating)}</td>
 
498
  if (currentPage < 1) currentPage = 1;
499
  if (total === 0) {
500
  countLabel.textContent = "0 records";
501
+ tbody.innerHTML = '<tr><td colspan="14" class="empty">No measurements yet</td></tr>';
502
  } else {
503
  const start = (currentPage - 1) * PAGE_SIZE;
504
  const slice = allRows.slice(start, start + PAGE_SIZE);
 
525
  currentPage = 1;
526
  renderPage();
527
  } catch (e) {
528
+ tbody.innerHTML = `<tr><td colspan="14" class="empty">Error loading data: ${e.message}</td></tr>`;
529
  }
530
  };
531
 
532
+ window.viewSessionJson = (btn) => {
533
+ const rowId = btn.closest("tr").dataset.id;
534
+ const row = allRows.find((item) => String(item.id) === String(rowId));
535
+ if (!row || !row.session_recommendation) return;
536
+
537
+ const dialog = document.getElementById("sessionJsonDialog");
538
+ const shortId = row.session_id ? ` · ${String(row.session_id).slice(0, 8)}` : "";
539
+ document.getElementById("sessionJsonTitle").textContent = `Session Recommendation${shortId}`;
540
+ document.getElementById("sessionJsonOutput").textContent = JSON.stringify(
541
+ row.session_recommendation,
542
+ null,
543
+ 2,
544
+ );
545
+ if (typeof dialog.showModal === "function") dialog.showModal();
546
+ else dialog.setAttribute("open", "");
547
+ };
548
+
549
+ window.closeSessionJson = () => {
550
+ const dialog = document.getElementById("sessionJsonDialog");
551
+ if (typeof dialog.close === "function") dialog.close();
552
+ else dialog.removeAttribute("open");
553
+ };
554
+
555
  window.deleteRow = async (btn) => {
556
  if (!confirm("Delete this measurement record?")) return;
557
  const tr = btn.closest("tr");