@hatk/hatk 0.0.1-alpha.103 → 0.0.1-alpha.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/admin.html CHANGED
@@ -935,6 +935,132 @@
935
935
  border-radius: 10px;
936
936
  background: var(--bg);
937
937
  }
938
+ /* ── Review queue ── */
939
+ .review-card {
940
+ border-bottom: 1px solid var(--border);
941
+ padding: 0.9rem 1rem 0.9rem 0.75rem;
942
+ border-left: 3px solid transparent;
943
+ transition:
944
+ background 0.1s,
945
+ border-color 0.1s;
946
+ }
947
+ .review-card:last-child {
948
+ border-bottom: none;
949
+ }
950
+ .review-card.selected {
951
+ background: #faf8f6;
952
+ border-left-color: var(--accent);
953
+ }
954
+ .review-top {
955
+ display: flex;
956
+ align-items: flex-start;
957
+ justify-content: space-between;
958
+ gap: 0.75rem;
959
+ }
960
+ .review-handle {
961
+ font-weight: 650;
962
+ font-size: 0.95rem;
963
+ color: var(--text);
964
+ }
965
+ .review-score {
966
+ font-family: var(--mono);
967
+ font-size: 0.8rem;
968
+ font-weight: 700;
969
+ padding: 0.15rem 0.45rem;
970
+ border-radius: 6px;
971
+ flex-shrink: 0;
972
+ background: var(--bg);
973
+ color: var(--text-2);
974
+ }
975
+ .review-score.high {
976
+ background: #fdeaea;
977
+ color: #b4242a;
978
+ }
979
+ .review-score.mid {
980
+ background: #fdf3e3;
981
+ color: #9a6412;
982
+ }
983
+ .signal-row {
984
+ display: flex;
985
+ gap: 0.3rem;
986
+ flex-wrap: wrap;
987
+ margin-top: 0.45rem;
988
+ }
989
+ .signal-chip {
990
+ font-family: var(--mono);
991
+ font-size: 0.7rem;
992
+ padding: 0.1rem 0.4rem;
993
+ border-radius: 5px;
994
+ background: var(--bg);
995
+ color: var(--text-3);
996
+ border: 1px solid var(--border);
997
+ }
998
+ .signal-chip.crossed {
999
+ background: #fdeaea;
1000
+ color: #b4242a;
1001
+ border-color: #f3c9c9;
1002
+ }
1003
+ .review-state {
1004
+ font-size: 0.85rem;
1005
+ color: var(--text-2);
1006
+ margin-top: 0.45rem;
1007
+ white-space: pre-wrap;
1008
+ word-break: break-word;
1009
+ }
1010
+ .review-state .k {
1011
+ font-family: var(--mono);
1012
+ font-size: 0.72rem;
1013
+ color: var(--text-3);
1014
+ }
1015
+ .review-thumbs {
1016
+ display: flex;
1017
+ gap: 0.35rem;
1018
+ flex-wrap: wrap;
1019
+ margin-top: 0.55rem;
1020
+ }
1021
+ .review-thumbs img {
1022
+ width: 72px;
1023
+ height: 72px;
1024
+ object-fit: cover;
1025
+ border-radius: 8px;
1026
+ background: var(--bg);
1027
+ }
1028
+ .review-actions {
1029
+ display: flex;
1030
+ gap: 0.3rem;
1031
+ align-items: center;
1032
+ margin-top: 0.6rem;
1033
+ flex-wrap: wrap;
1034
+ }
1035
+ .scan-bar {
1036
+ display: flex;
1037
+ align-items: center;
1038
+ gap: 0.6rem;
1039
+ flex-wrap: wrap;
1040
+ padding: 0.6rem 1rem;
1041
+ border-bottom: 1px solid var(--border);
1042
+ font-size: 0.82rem;
1043
+ color: var(--text-2);
1044
+ }
1045
+ .kbd {
1046
+ font-family: var(--mono);
1047
+ font-size: 0.68rem;
1048
+ padding: 0.05rem 0.3rem;
1049
+ border: 1px solid var(--border);
1050
+ border-bottom-width: 2px;
1051
+ border-radius: 4px;
1052
+ color: var(--text-3);
1053
+ background: #fff;
1054
+ }
1055
+ .review-hint {
1056
+ font-size: 0.75rem;
1057
+ color: var(--text-3);
1058
+ padding: 0.5rem 1rem;
1059
+ display: flex;
1060
+ gap: 0.7rem;
1061
+ flex-wrap: wrap;
1062
+ align-items: center;
1063
+ }
938
1064
  .blob-link {
939
1065
  font-family: var(--mono);
940
1066
  font-size: 0.72rem;
@@ -1213,6 +1339,7 @@
1213
1339
  <button class="tab active" data-tab="overview">Overview</button>
1214
1340
  <button class="tab" data-tab="repos">Repos</button>
1215
1341
  <button class="tab" data-tab="content">Content</button>
1342
+ <button class="tab" data-tab="review">Review</button>
1216
1343
  <button class="tab" data-tab="reports">Reports</button>
1217
1344
  </nav>
1218
1345
 
@@ -1289,6 +1416,28 @@
1289
1416
  </div>
1290
1417
 
1291
1418
  <!-- Reports -->
1419
+ <div class="tab-panel" id="panel-review">
1420
+ <div class="search-bar">
1421
+ <select class="search-input" id="review-classifier" style="max-width: 200px">
1422
+ <option value="">All classifiers</option>
1423
+ </select>
1424
+ <select class="search-input" id="review-state" style="max-width: 180px">
1425
+ <option value="pending">Needs review</option>
1426
+ <option value="actioned">Actioned</option>
1427
+ <option value="all">All scored</option>
1428
+ </select>
1429
+ <select class="search-input" id="review-min" style="max-width: 160px">
1430
+ <option value="0.5">Score &ge; 0.50</option>
1431
+ <option value="0.6">Score &ge; 0.60</option>
1432
+ <option value="0.7">Score &ge; 0.70</option>
1433
+ <option value="0.8">Score &ge; 0.80</option>
1434
+ <option value="0">Any score</option>
1435
+ </select>
1436
+ <button class="btn btn-sm" id="review-scan-btn">Scan</button>
1437
+ </div>
1438
+ <div id="review-results"></div>
1439
+ </div>
1440
+
1292
1441
  <div class="tab-panel" id="panel-reports">
1293
1442
  <div class="search-bar">
1294
1443
  <select class="search-input" id="reports-status" style="max-width: 200px">
@@ -1310,6 +1459,7 @@
1310
1459
  <button class="bnav-btn active" data-tab="overview">Overview</button>
1311
1460
  <button class="bnav-btn" data-tab="repos">Repos</button>
1312
1461
  <button class="bnav-btn" data-tab="content">Content</button>
1462
+ <button class="bnav-btn" data-tab="review">Review</button>
1313
1463
  <button class="bnav-btn" data-tab="reports">Reports</button>
1314
1464
  </div>
1315
1465
  </div>
@@ -1502,6 +1652,7 @@
1502
1652
  if (tab === 'overview') loadOverview()
1503
1653
  if (tab === 'repos') loadRepos()
1504
1654
  if (tab === 'content') loadContent()
1655
+ if (tab === 'review') loadReview()
1505
1656
  if (tab === 'reports') loadReports()
1506
1657
  if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' })
1507
1658
  }
@@ -2168,6 +2319,330 @@
2168
2319
  loadReports()
2169
2320
  })
2170
2321
 
2322
+ // ── Review queue ──
2323
+ //
2324
+ // The queue exists so nobody has to look at every account. Each card
2325
+ // carries the state the model was shown and the score it returned, so a
2326
+ // decision can be made from the list without opening anything.
2327
+
2328
+ const review = { items: [], index: 0, total: 0, offset: 0, limit: 25, thumbs: new Map() }
2329
+ let reviewClassifiersLoaded = false
2330
+ let scanPoll = null
2331
+
2332
+ function scoreClass(v) {
2333
+ return v >= 0.8 ? 'high' : v >= 0.6 ? 'mid' : ''
2334
+ }
2335
+
2336
+ async function loadReview() {
2337
+ const container = document.getElementById('review-results')
2338
+ if (!reviewClassifiersLoaded) {
2339
+ try {
2340
+ const stats = await api('/admin/review/stats')
2341
+ const sel = document.getElementById('review-classifier')
2342
+ for (const name of stats.classifiers || []) {
2343
+ const o = document.createElement('option')
2344
+ o.value = o.textContent = name
2345
+ sel.appendChild(o)
2346
+ }
2347
+ reviewClassifiersLoaded = true
2348
+ } catch (e) {
2349
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
2350
+ return
2351
+ }
2352
+ }
2353
+ const classifier = document.getElementById('review-classifier').value
2354
+ const state = document.getElementById('review-state').value
2355
+ const minScore = document.getElementById('review-min').value
2356
+ container.innerHTML = '<div class="loading">Loading</div>'
2357
+ try {
2358
+ let url = `/admin/review?state=${state}&minScore=${minScore}&limit=${review.limit}&offset=${review.offset}`
2359
+ if (classifier) url += `&classifier=${encodeURIComponent(classifier)}`
2360
+ const result = await api(url)
2361
+ review.items = result.rows || []
2362
+ review.total = result.total
2363
+ review.index = 0
2364
+ renderReview()
2365
+ } catch (e) {
2366
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
2367
+ }
2368
+ }
2369
+
2370
+ function renderScanBar(stats) {
2371
+ const el = document.getElementById('review-scan-status')
2372
+ if (!el) return
2373
+ if (!stats.configured) {
2374
+ el.textContent = 'Clef not configured — set `clef` in hatk.config.ts'
2375
+ return
2376
+ }
2377
+ const scan = stats.scan
2378
+ if (scan?.running) {
2379
+ el.textContent = `Scanning… ${scan.scored} scored, ${scan.skipped} skipped, ${scan.filed} filed`
2380
+ } else if (scan) {
2381
+ const cost = ((scan.inputTokens / 1e6) * 0.042).toFixed(3)
2382
+ el.textContent = `Last scan: ${scan.scored} scored, ${scan.skipped} unchanged, ${scan.filed} filed, $${cost}`
2383
+ } else {
2384
+ el.textContent = `${stats.total} scored · ${stats.pending} awaiting review`
2385
+ }
2386
+ }
2387
+
2388
+ // Render the model's state as short "key: value" lines. Long values are
2389
+ // clipped rather than dropped: the reason an account looks wrong is often
2390
+ // the first few words of its bio.
2391
+ function renderState(state) {
2392
+ if (!state || typeof state !== 'object') return ''
2393
+ const parts = []
2394
+ for (const [k, v] of Object.entries(state)) {
2395
+ if (v == null || v === '') continue
2396
+ let text
2397
+ if (typeof v === 'string') text = v
2398
+ else if (Array.isArray(v)) {
2399
+ text = v
2400
+ .map((x) => (typeof x === 'string' ? x : x?.title || x?.text || JSON.stringify(x)))
2401
+ .filter(Boolean)
2402
+ .slice(0, 3)
2403
+ .join(' · ')
2404
+ } else if (typeof v === 'object') {
2405
+ text = Object.entries(v)
2406
+ .filter(([, n]) => n)
2407
+ .map(([n, val]) => `${n} ${val}`)
2408
+ .join(', ')
2409
+ } else text = String(v)
2410
+ if (!text) continue
2411
+ if (text.length > 180) text = text.slice(0, 180) + '…'
2412
+ parts.push(`<span class="k">${escapeHtml(k)}</span> ${escapeHtml(text)}`)
2413
+ }
2414
+ return parts.join('<br>')
2415
+ }
2416
+
2417
+ function renderReview() {
2418
+ const container = document.getElementById('review-results')
2419
+ const labelOptions = labelDefinitions
2420
+ .map((d) => `<option value="${d.identifier}">${d.identifier}</option>`)
2421
+ .join('')
2422
+
2423
+ if (!review.items.length) {
2424
+ container.innerHTML = `
2425
+ <div class="scan-bar"><span id="review-scan-status"></span></div>
2426
+ <div class="empty-state">Nothing awaiting review</div>`
2427
+ api('/admin/review/stats')
2428
+ .then(renderScanBar)
2429
+ .catch(() => {})
2430
+ return
2431
+ }
2432
+
2433
+ const showPagination = review.total > review.limit
2434
+ container.innerHTML = `
2435
+ <div class="card">
2436
+ <div class="scan-bar"><span id="review-scan-status"></span></div>
2437
+ <div class="review-hint">
2438
+ <span><span class="kbd">j</span><span class="kbd">k</span> move</span>
2439
+ <span><span class="kbd">t</span> takedown</span>
2440
+ <span><span class="kbd">l</span> label</span>
2441
+ <span><span class="kbd">d</span> dismiss</span>
2442
+ </div>
2443
+ ${review.items
2444
+ .map((r, i) => {
2445
+ const signals = Object.entries(r.signals || {}).sort((a, b) => b[1].score - a[1].score)
2446
+ const top = Number(r.top_score || 0)
2447
+ const handle = r.handle ? '@' + escapeHtml(r.handle) : escapeHtml(r.subject_did)
2448
+ const takendown = r.repo_status === 'takendown'
2449
+ return `<div class="review-card${i === review.index ? ' selected' : ''}" data-review-index="${i}">
2450
+ <div class="review-top">
2451
+ <div style="min-width:0">
2452
+ <div class="review-handle">${handle}${takendown ? ' <span class="label-tag">taken down</span>' : ''}</div>
2453
+ <div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div>
2454
+ </div>
2455
+ <div class="review-score ${scoreClass(top)}">${top.toFixed(2)}</div>
2456
+ </div>
2457
+ <div class="signal-row">
2458
+ ${signals
2459
+ .map(
2460
+ ([name, sig]) =>
2461
+ `<span class="signal-chip${sig.score >= 0.5 ? ' crossed' : ''}" title="${escapeHtml(sig.classifier || '')}">${escapeHtml(name)} ${sig.score.toFixed(2)}${
2462
+ sig.choice ? ' · ' + escapeHtml(String(sig.choice)) : ''
2463
+ }</span>`,
2464
+ )
2465
+ .join('')}
2466
+ </div>
2467
+ <div class="review-state">${renderState(r.state)}</div>
2468
+ <div class="review-thumbs" data-thumbs-for="${escapeHtml(r.subject_did)}"></div>
2469
+ <div class="review-actions">
2470
+ <button class="btn btn-sm" data-review-act="takedown" data-i="${i}" style="background:#b4242a;color:white">Takedown</button>
2471
+ <select class="search-input" data-review-label="${i}" style="max-width:150px;font-size:0.8rem;padding:0.25rem 0.5rem">
2472
+ ${labelOptions}
2473
+ </select>
2474
+ <button class="btn btn-sm" data-review-act="label" data-i="${i}">Apply label</button>
2475
+ <button class="btn btn-sm" data-review-act="dismiss" data-i="${i}">Dismiss</button>
2476
+ <a class="btn btn-sm" href="/admin?tab=repos&q=${encodeURIComponent(r.subject_did)}">Open in Repos</a>
2477
+ </div>
2478
+ </div>`
2479
+ })
2480
+ .join('')}
2481
+ ${
2482
+ showPagination
2483
+ ? `<div class="pagination">
2484
+ <span>${review.offset + 1}–${Math.min(review.offset + review.limit, review.total)} of ${review.total.toLocaleString()}</span>
2485
+ <div class="pagination-buttons">
2486
+ <button class="btn btn-sm" data-review-page="prev" ${review.offset === 0 ? 'disabled' : ''}>Prev</button>
2487
+ <button class="btn btn-sm" data-review-page="next" ${review.offset + review.limit >= review.total ? 'disabled' : ''}>Next</button>
2488
+ </div>
2489
+ </div>`
2490
+ : ''
2491
+ }
2492
+ </div>`
2493
+
2494
+ api('/admin/review/stats')
2495
+ .then(renderScanBar)
2496
+ .catch(() => {})
2497
+ wireReview()
2498
+ loadReviewThumbs()
2499
+ }
2500
+
2501
+ // Thumbnails come from a second request per subject, so they are fetched
2502
+ // after the cards are on screen and cached — the queue stays useful
2503
+ // whether or not they arrive.
2504
+ async function loadReviewThumbs() {
2505
+ for (const r of review.items) {
2506
+ const holder = document.querySelector(`[data-thumbs-for="${CSS.escape(r.subject_did)}"]`)
2507
+ if (!holder || holder.dataset.done) continue
2508
+ holder.dataset.done = '1'
2509
+ try {
2510
+ let ctx = review.thumbs.get(r.subject_did)
2511
+ if (!ctx) {
2512
+ ctx = await api(`/admin/review/subject/${encodeURIComponent(r.subject_did)}`)
2513
+ review.thumbs.set(r.subject_did, ctx)
2514
+ }
2515
+ const blobs = []
2516
+ for (const records of Object.values(ctx.records || {})) {
2517
+ for (const rec of records) {
2518
+ for (const b of findBlobs(rec.value || rec)) {
2519
+ if (b.cid && (b.mimeType || '').startsWith('image/')) blobs.push(b)
2520
+ }
2521
+ }
2522
+ }
2523
+ holder.innerHTML = blobs
2524
+ .slice(0, 6)
2525
+ .map((b) => `<img src="${blobUrl(r.subject_did, b.cid)}" loading="lazy" alt="">`)
2526
+ .join('')
2527
+ } catch {
2528
+ /* context is a nicety; the score and state already carry the decision */
2529
+ }
2530
+ }
2531
+ }
2532
+
2533
+ function selectReview(i) {
2534
+ if (!review.items.length) return
2535
+ review.index = Math.max(0, Math.min(review.items.length - 1, i))
2536
+ document.querySelectorAll('.review-card').forEach((el, n) => {
2537
+ el.classList.toggle('selected', n === review.index)
2538
+ if (n === review.index) el.scrollIntoView({ block: 'nearest' })
2539
+ })
2540
+ }
2541
+
2542
+ async function actOnReview(i, action) {
2543
+ const item = review.items[i]
2544
+ if (!item) return
2545
+ const body = { did: item.subject_did, uri: item.subject_uri, action }
2546
+ if (action === 'label') {
2547
+ const sel = document.querySelector(`[data-review-label="${i}"]`)
2548
+ body.label = sel?.value
2549
+ if (!body.label) return toast('Pick a label first', 'error')
2550
+ }
2551
+ try {
2552
+ await api('/admin/review/act', { method: 'POST', body: JSON.stringify(body) })
2553
+ toast(action === 'dismiss' ? 'Dismissed' : action === 'takedown' ? 'Taken down' : 'Label applied')
2554
+ review.items.splice(i, 1)
2555
+ review.total = Math.max(0, review.total - 1)
2556
+ if (review.index >= review.items.length) review.index = Math.max(0, review.items.length - 1)
2557
+ renderReview()
2558
+ } catch (e) {
2559
+ toast(e.message, 'error')
2560
+ }
2561
+ }
2562
+
2563
+ function wireReview() {
2564
+ document.querySelectorAll('[data-review-index]').forEach((el) => {
2565
+ el.addEventListener('click', (e) => {
2566
+ if (e.target.closest('button,select,a')) return
2567
+ selectReview(Number(el.dataset.reviewIndex))
2568
+ })
2569
+ })
2570
+ document.querySelectorAll('[data-review-act]').forEach((b) => {
2571
+ b.addEventListener('click', () => actOnReview(Number(b.dataset.i), b.dataset.reviewAct))
2572
+ })
2573
+ document.querySelectorAll('[data-review-page]').forEach((b) => {
2574
+ b.addEventListener('click', () => {
2575
+ review.offset += b.dataset.reviewPage === 'next' ? review.limit : -review.limit
2576
+ review.offset = Math.max(0, review.offset)
2577
+ loadReview()
2578
+ })
2579
+ })
2580
+ }
2581
+
2582
+ document.getElementById('review-classifier').addEventListener('change', () => {
2583
+ review.offset = 0
2584
+ loadReview()
2585
+ })
2586
+ document.getElementById('review-state').addEventListener('change', () => {
2587
+ review.offset = 0
2588
+ loadReview()
2589
+ })
2590
+ document.getElementById('review-min').addEventListener('change', () => {
2591
+ review.offset = 0
2592
+ loadReview()
2593
+ })
2594
+
2595
+ document.getElementById('review-scan-btn').addEventListener('click', async () => {
2596
+ const classifier = document.getElementById('review-classifier').value
2597
+ try {
2598
+ await api('/admin/review/scan', {
2599
+ method: 'POST',
2600
+ body: JSON.stringify(classifier ? { classifier } : {}),
2601
+ })
2602
+ toast('Scan started')
2603
+ if (scanPoll) clearInterval(scanPoll)
2604
+ scanPoll = setInterval(async () => {
2605
+ try {
2606
+ const stats = await api('/admin/review/stats')
2607
+ renderScanBar(stats)
2608
+ if (!stats.scan?.running) {
2609
+ clearInterval(scanPoll)
2610
+ scanPoll = null
2611
+ loadReview()
2612
+ }
2613
+ } catch {
2614
+ clearInterval(scanPoll)
2615
+ scanPoll = null
2616
+ }
2617
+ }, 2000)
2618
+ } catch (e) {
2619
+ toast(e.message, 'error')
2620
+ }
2621
+ })
2622
+
2623
+ document.addEventListener('keydown', (e) => {
2624
+ const panel = document.getElementById('panel-review')
2625
+ if (!panel?.classList.contains('active')) return
2626
+ if (e.target.matches('input,select,textarea')) return
2627
+ const key = e.key.toLowerCase()
2628
+ if (key === 'j' || e.key === 'ArrowDown') {
2629
+ e.preventDefault()
2630
+ selectReview(review.index + 1)
2631
+ } else if (key === 'k' || e.key === 'ArrowUp') {
2632
+ e.preventDefault()
2633
+ selectReview(review.index - 1)
2634
+ } else if (key === 't') {
2635
+ e.preventDefault()
2636
+ actOnReview(review.index, 'takedown')
2637
+ } else if (key === 'l') {
2638
+ e.preventDefault()
2639
+ actOnReview(review.index, 'label')
2640
+ } else if (key === 'd' || key === 'x') {
2641
+ e.preventDefault()
2642
+ actOnReview(review.index, 'dismiss')
2643
+ }
2644
+ })
2645
+
2171
2646
  async function loadReports() {
2172
2647
  populateReportsLabelFilter()
2173
2648
  const status = document.getElementById('reports-status').value