@hatk/hatk 0.0.1-alpha.95 → 0.0.1-alpha.97

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