@hatk/hatk 0.0.1-alpha.96 → 0.0.1-alpha.98
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/dist/backfill.d.ts +4 -1
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +17 -5
- package/dist/cli.js +0 -1
- package/dist/config.d.ts +1 -14
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -11
- package/dist/database/db.d.ts +13 -64
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +27 -235
- package/dist/indexer.d.ts +18 -0
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +65 -1
- package/dist/jetstream.d.ts.map +1 -1
- package/dist/jetstream.js +13 -3
- package/dist/main.js +0 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -89
- package/package.json +2 -3
- package/public/admin.html +0 -467
- package/dist/classifiers.d.ts +0 -168
- package/dist/classifiers.d.ts.map +0 -1
- package/dist/classifiers.js +0 -370
package/public/admin.html
CHANGED
|
@@ -935,130 +935,6 @@
|
|
|
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
|
-
}
|
|
1062
938
|
.blob-link {
|
|
1063
939
|
font-family: var(--mono);
|
|
1064
940
|
font-size: 0.72rem;
|
|
@@ -1337,7 +1213,6 @@
|
|
|
1337
1213
|
<button class="tab active" data-tab="overview">Overview</button>
|
|
1338
1214
|
<button class="tab" data-tab="repos">Repos</button>
|
|
1339
1215
|
<button class="tab" data-tab="content">Content</button>
|
|
1340
|
-
<button class="tab" data-tab="review">Review</button>
|
|
1341
1216
|
<button class="tab" data-tab="reports">Reports</button>
|
|
1342
1217
|
</nav>
|
|
1343
1218
|
|
|
@@ -1414,28 +1289,6 @@
|
|
|
1414
1289
|
</div>
|
|
1415
1290
|
|
|
1416
1291
|
<!-- 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 ≥ 0.50</option>
|
|
1429
|
-
<option value="0.6">Score ≥ 0.60</option>
|
|
1430
|
-
<option value="0.7">Score ≥ 0.70</option>
|
|
1431
|
-
<option value="0.8">Score ≥ 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
|
-
|
|
1439
1292
|
<div class="tab-panel" id="panel-reports">
|
|
1440
1293
|
<div class="search-bar">
|
|
1441
1294
|
<select class="search-input" id="reports-status" style="max-width: 200px">
|
|
@@ -1457,7 +1310,6 @@
|
|
|
1457
1310
|
<button class="bnav-btn active" data-tab="overview">Overview</button>
|
|
1458
1311
|
<button class="bnav-btn" data-tab="repos">Repos</button>
|
|
1459
1312
|
<button class="bnav-btn" data-tab="content">Content</button>
|
|
1460
|
-
<button class="bnav-btn" data-tab="review">Review</button>
|
|
1461
1313
|
<button class="bnav-btn" data-tab="reports">Reports</button>
|
|
1462
1314
|
</div>
|
|
1463
1315
|
</div>
|
|
@@ -1650,7 +1502,6 @@
|
|
|
1650
1502
|
if (tab === 'overview') loadOverview()
|
|
1651
1503
|
if (tab === 'repos') loadRepos()
|
|
1652
1504
|
if (tab === 'content') loadContent()
|
|
1653
|
-
if (tab === 'review') loadReview()
|
|
1654
1505
|
if (tab === 'reports') loadReports()
|
|
1655
1506
|
if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' })
|
|
1656
1507
|
}
|
|
@@ -2317,324 +2168,6 @@
|
|
|
2317
2168
|
loadReports()
|
|
2318
2169
|
})
|
|
2319
2170
|
|
|
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
|
-
|
|
2638
2171
|
async function loadReports() {
|
|
2639
2172
|
populateReportsLabelFilter()
|
|
2640
2173
|
const status = document.getElementById('reports-status').value
|
package/dist/classifiers.d.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/** A typed question in the shape the System One API accepts. */
|
|
2
|
-
export interface ClassifierQuestion {
|
|
3
|
-
type: 'noul' | 'choice' | 'score';
|
|
4
|
-
instructions: string | Record<string, unknown> | unknown[];
|
|
5
|
-
criteria?: unknown;
|
|
6
|
-
}
|
|
7
|
-
/** The thing being scored: an account (by DID) or a single record. */
|
|
8
|
-
export interface ClassifierSubject {
|
|
9
|
-
/** `at://` URI for a record subject; the bare DID for an account subject. */
|
|
10
|
-
uri: string;
|
|
11
|
-
did: string;
|
|
12
|
-
handle?: string | null;
|
|
13
|
-
collection?: string;
|
|
14
|
-
value?: Record<string, any>;
|
|
15
|
-
}
|
|
16
|
-
export interface ClassifierContext {
|
|
17
|
-
db: {
|
|
18
|
-
query: (sql: string, params?: any[]) => Promise<any[]>;
|
|
19
|
-
run: (sql: string, ...params: any[]) => Promise<void>;
|
|
20
|
-
};
|
|
21
|
-
subject: ClassifierSubject;
|
|
22
|
-
}
|
|
23
|
-
export interface ClassifierModule {
|
|
24
|
-
/** `account` scans every indexed repo; `record` scans rows of `collections`. */
|
|
25
|
-
subject: 'account' | 'record';
|
|
26
|
-
/** Collections to scan. Required when `subject` is `record`. */
|
|
27
|
-
collections?: string[];
|
|
28
|
-
/** The questions to ask. Answers come back under the same keys. Omit when
|
|
29
|
-
* the classifier supplies its own `classify`. */
|
|
30
|
-
questions?: Record<string, ClassifierQuestion>;
|
|
31
|
-
/**
|
|
32
|
-
* Produce signals without asking a System One model.
|
|
33
|
-
*
|
|
34
|
-
* A classifier that runs its own model — an image classifier on the host, a
|
|
35
|
-
* hash lookup, a heuristic — sets this instead of `questions`. Everything
|
|
36
|
-
* downstream is unchanged: the same thresholds, the same report, the same
|
|
37
|
-
* review card. Return a bare number for a plain score.
|
|
38
|
-
*/
|
|
39
|
-
classify?: (state: Record<string, unknown>, ctx: ClassifierContext) => Promise<Record<string, number | Signal>>;
|
|
40
|
-
/**
|
|
41
|
-
* Build the model `state` for this subject. Return `null` to skip it — a
|
|
42
|
-
* skipped subject costs no tokens. Gate on whatever makes the question
|
|
43
|
-
* answerable: an account with no content cannot be judged on its content.
|
|
44
|
-
*/
|
|
45
|
-
buildState: (ctx: ClassifierContext) => Promise<Record<string, unknown> | null>;
|
|
46
|
-
/**
|
|
47
|
-
* Score at or above which a signal files a report. A number applies to every
|
|
48
|
-
* question; a map sets it per question. Questions absent from the map never
|
|
49
|
-
* file, though their scores are still stored and shown.
|
|
50
|
-
*/
|
|
51
|
-
threshold?: number | Record<string, number>;
|
|
52
|
-
/** Label the filed report carries. A function receives the crossing signal. */
|
|
53
|
-
label?: string | ((signal: string, score: number) => string);
|
|
54
|
-
}
|
|
55
|
-
export declare function defineClassifier(module: ClassifierModule): {
|
|
56
|
-
/** `account` scans every indexed repo; `record` scans rows of `collections`. */
|
|
57
|
-
subject: "account" | "record";
|
|
58
|
-
/** Collections to scan. Required when `subject` is `record`. */
|
|
59
|
-
collections?: string[];
|
|
60
|
-
/** The questions to ask. Answers come back under the same keys. Omit when
|
|
61
|
-
* the classifier supplies its own `classify`. */
|
|
62
|
-
questions?: Record<string, ClassifierQuestion>;
|
|
63
|
-
/**
|
|
64
|
-
* Produce signals without asking a System One model.
|
|
65
|
-
*
|
|
66
|
-
* A classifier that runs its own model — an image classifier on the host, a
|
|
67
|
-
* hash lookup, a heuristic — sets this instead of `questions`. Everything
|
|
68
|
-
* downstream is unchanged: the same thresholds, the same report, the same
|
|
69
|
-
* review card. Return a bare number for a plain score.
|
|
70
|
-
*/
|
|
71
|
-
classify?: (state: Record<string, unknown>, ctx: ClassifierContext) => Promise<Record<string, number | Signal>>;
|
|
72
|
-
/**
|
|
73
|
-
* Build the model `state` for this subject. Return `null` to skip it — a
|
|
74
|
-
* skipped subject costs no tokens. Gate on whatever makes the question
|
|
75
|
-
* answerable: an account with no content cannot be judged on its content.
|
|
76
|
-
*/
|
|
77
|
-
buildState: (ctx: ClassifierContext) => Promise<Record<string, unknown> | null>;
|
|
78
|
-
/**
|
|
79
|
-
* Score at or above which a signal files a report. A number applies to every
|
|
80
|
-
* question; a map sets it per question. Questions absent from the map never
|
|
81
|
-
* file, though their scores are still stored and shown.
|
|
82
|
-
*/
|
|
83
|
-
threshold?: number | Record<string, number>;
|
|
84
|
-
/** Label the filed report carries. A function receives the crossing signal. */
|
|
85
|
-
label?: string | ((signal: string, score: number) => string);
|
|
86
|
-
__type: "classifiers";
|
|
87
|
-
};
|
|
88
|
-
interface LoadedClassifier extends ClassifierModule {
|
|
89
|
-
name: string;
|
|
90
|
-
}
|
|
91
|
-
/** Discover and load classifier modules from the `classifiers/` directory. */
|
|
92
|
-
export declare function initClassifiers(classifiersDir: string): Promise<void>;
|
|
93
|
-
/**
|
|
94
|
-
* Register a classifier directly, bypassing directory discovery.
|
|
95
|
-
*
|
|
96
|
-
* Mirrors {@link registerLabelModule}: tests and embedders that build their own
|
|
97
|
-
* module graph have no `classifiers/` directory to read.
|
|
98
|
-
*/
|
|
99
|
-
export declare function registerClassifier(name: string, module: ClassifierModule): void;
|
|
100
|
-
/** Clear all registered classifiers (for hot-reload). */
|
|
101
|
-
export declare function clearClassifiers(): void;
|
|
102
|
-
/** Names of all loaded classifiers. */
|
|
103
|
-
export declare function listClassifiers(): string[];
|
|
104
|
-
export declare function getClassifier(name: string): LoadedClassifier | undefined;
|
|
105
|
-
export interface SystemOneConfig {
|
|
106
|
-
apiKey: string;
|
|
107
|
-
model: string;
|
|
108
|
-
endpoint: string;
|
|
109
|
-
}
|
|
110
|
-
export declare function configureSystemOne(config: SystemOneConfig | null): void;
|
|
111
|
-
export declare function isSystemOneConfigured(): boolean;
|
|
112
|
-
/** A single answer, normalized to a 0–1 score regardless of question type. */
|
|
113
|
-
export interface Signal {
|
|
114
|
-
score: number;
|
|
115
|
-
type: string;
|
|
116
|
-
/** Choice/Score only: the selected option and its confidence. */
|
|
117
|
-
choice?: string;
|
|
118
|
-
confidence?: number;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Ask the model one request's worth of questions about one state.
|
|
122
|
-
*
|
|
123
|
-
* Retries on 429 and 5xx, honoring `retry-after` when the response carries one.
|
|
124
|
-
* The caller's concurrency is what keeps us under the rate limit; this only
|
|
125
|
-
* recovers the requests that still slip past it.
|
|
126
|
-
*/
|
|
127
|
-
export declare function askSystemOne(state: Record<string, unknown>, questions: Record<string, ClassifierQuestion>, opts?: {
|
|
128
|
-
attempts?: number;
|
|
129
|
-
signal?: AbortSignal;
|
|
130
|
-
}): Promise<{
|
|
131
|
-
answers: Record<string, Signal>;
|
|
132
|
-
model: string;
|
|
133
|
-
inputTokens: number;
|
|
134
|
-
}>;
|
|
135
|
-
export interface ScanProgress {
|
|
136
|
-
/** Subjects considered. */
|
|
137
|
-
scanned: number;
|
|
138
|
-
/** Gated out by `buildState` returning null, or unchanged since the last scan. */
|
|
139
|
-
skipped: number;
|
|
140
|
-
/** Sent to the model and stored. */
|
|
141
|
-
scored: number;
|
|
142
|
-
/** Reports filed for crossing a threshold. */
|
|
143
|
-
filed: number;
|
|
144
|
-
errors: number;
|
|
145
|
-
inputTokens: number;
|
|
146
|
-
running: boolean;
|
|
147
|
-
startedAt: string;
|
|
148
|
-
finishedAt?: string;
|
|
149
|
-
error?: string;
|
|
150
|
-
}
|
|
151
|
-
/** Progress of the scan in flight, or the last one to finish. */
|
|
152
|
-
export declare function getScanProgress(): ScanProgress | null;
|
|
153
|
-
/**
|
|
154
|
-
* Score every subject a classifier applies to, storing results and filing a
|
|
155
|
-
* report for each crossing signal.
|
|
156
|
-
*
|
|
157
|
-
* Subjects whose state is unchanged since the last scan are skipped unless
|
|
158
|
-
* `force` is set, so a repeat scan costs only what actually moved.
|
|
159
|
-
*/
|
|
160
|
-
export declare function runScan(opts?: {
|
|
161
|
-
classifier?: string;
|
|
162
|
-
force?: boolean;
|
|
163
|
-
limit?: number;
|
|
164
|
-
concurrency?: number;
|
|
165
|
-
signal?: AbortSignal;
|
|
166
|
-
}): Promise<ScanProgress>;
|
|
167
|
-
export {};
|
|
168
|
-
//# sourceMappingURL=classifiers.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"classifiers.d.ts","sourceRoot":"","sources":["../src/classifiers.ts"],"names":[],"mappings":"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"}
|