@hatk/hatk 0.0.1-alpha.97 → 0.0.1-alpha.99
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 +16 -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/dist/classifiers.js
DELETED
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
-
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
-
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
-
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
return path;
|
|
8
|
-
};
|
|
9
|
-
/**
|
|
10
|
-
* Classifier system for scoring accounts and records with a System One model.
|
|
11
|
-
*
|
|
12
|
-
* A classifier is the moderation counterpart to a label rule: where a label rule
|
|
13
|
-
* decides from the record alone, a classifier asks a model a set of narrow typed
|
|
14
|
-
* questions and stores the probabilities. Nothing acts on a score — crossing a
|
|
15
|
-
* threshold files a report into the same `_reports` queue a user report lands in,
|
|
16
|
-
* so a person still makes every enforcement decision.
|
|
17
|
-
*
|
|
18
|
-
* Place classifier modules in the `classifiers/` directory.
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* ```ts
|
|
22
|
-
* // classifiers/impersonation.ts
|
|
23
|
-
* import { defineClassifier } from '$hatk'
|
|
24
|
-
*
|
|
25
|
-
* export default defineClassifier({
|
|
26
|
-
* subject: 'account',
|
|
27
|
-
* label: 'impersonation',
|
|
28
|
-
* threshold: 0.7,
|
|
29
|
-
* questions: {
|
|
30
|
-
* passing_as: {
|
|
31
|
-
* type: 'noul',
|
|
32
|
-
* instructions: 'Is this account trying to be mistaken for a famous person?',
|
|
33
|
-
* },
|
|
34
|
-
* },
|
|
35
|
-
* async buildState({ subject, db }) {
|
|
36
|
-
* const [p] = await db.query(`SELECT description FROM profile WHERE did = $1`, [subject.did])
|
|
37
|
-
* return { handle: subject.handle, bio: p?.description }
|
|
38
|
-
* },
|
|
39
|
-
* })
|
|
40
|
-
* ```
|
|
41
|
-
*/
|
|
42
|
-
import { resolve } from 'node:path';
|
|
43
|
-
import { readdirSync } from 'node:fs';
|
|
44
|
-
import { createHash } from 'node:crypto';
|
|
45
|
-
import { querySQL, runSQL, getSchema, upsertClassification, getClassificationFingerprints, findOpenReport, insertReport, } from "./database/db.js";
|
|
46
|
-
import { log, emit } from "./logger.js";
|
|
47
|
-
export function defineClassifier(module) {
|
|
48
|
-
return { __type: 'classifiers', ...module };
|
|
49
|
-
}
|
|
50
|
-
const classifiers = [];
|
|
51
|
-
/** Discover and load classifier modules from the `classifiers/` directory. */
|
|
52
|
-
export async function initClassifiers(classifiersDir) {
|
|
53
|
-
let files;
|
|
54
|
-
try {
|
|
55
|
-
files = readdirSync(classifiersDir)
|
|
56
|
-
.filter((f) => (f.endsWith('.ts') || f.endsWith('.js')) && !f.startsWith('_'))
|
|
57
|
-
.sort();
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
for (const file of files) {
|
|
63
|
-
const name = file.replace(/\.(ts|js)$/, '');
|
|
64
|
-
const scriptPath = resolve(classifiersDir, file);
|
|
65
|
-
const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`));
|
|
66
|
-
const handler = mod.default;
|
|
67
|
-
if (!handler?.buildState || !(handler.questions || handler.classify)) {
|
|
68
|
-
log(`[classifiers] skipped ${name}: needs buildState plus questions or classify`);
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
classifiers.push({ name, ...handler });
|
|
72
|
-
log(`[classifiers] discovered: ${name} (${handler.subject})`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Register a classifier directly, bypassing directory discovery.
|
|
77
|
-
*
|
|
78
|
-
* Mirrors {@link registerLabelModule}: tests and embedders that build their own
|
|
79
|
-
* module graph have no `classifiers/` directory to read.
|
|
80
|
-
*/
|
|
81
|
-
export function registerClassifier(name, module) {
|
|
82
|
-
classifiers.push({ name, ...module });
|
|
83
|
-
}
|
|
84
|
-
/** Clear all registered classifiers (for hot-reload). */
|
|
85
|
-
export function clearClassifiers() {
|
|
86
|
-
classifiers.length = 0;
|
|
87
|
-
}
|
|
88
|
-
/** Names of all loaded classifiers. */
|
|
89
|
-
export function listClassifiers() {
|
|
90
|
-
return classifiers.map((c) => c.name);
|
|
91
|
-
}
|
|
92
|
-
export function getClassifier(name) {
|
|
93
|
-
return classifiers.find((c) => c.name === name);
|
|
94
|
-
}
|
|
95
|
-
let systemOne = null;
|
|
96
|
-
export function configureSystemOne(config) {
|
|
97
|
-
systemOne = config;
|
|
98
|
-
}
|
|
99
|
-
export function isSystemOneConfigured() {
|
|
100
|
-
return systemOne !== null;
|
|
101
|
-
}
|
|
102
|
-
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
|
|
103
|
-
/**
|
|
104
|
-
* Ask the model one request's worth of questions about one state.
|
|
105
|
-
*
|
|
106
|
-
* Retries on 429 and 5xx, honoring `retry-after` when the response carries one.
|
|
107
|
-
* The caller's concurrency is what keeps us under the rate limit; this only
|
|
108
|
-
* recovers the requests that still slip past it.
|
|
109
|
-
*/
|
|
110
|
-
export async function askSystemOne(state, questions, opts = {}) {
|
|
111
|
-
if (!systemOne)
|
|
112
|
-
throw new Error('System One is not configured — set `typesafe` in hatk.config.ts');
|
|
113
|
-
const attempts = opts.attempts ?? 4;
|
|
114
|
-
const body = JSON.stringify({ state, model: systemOne.model, questions });
|
|
115
|
-
let lastError = null;
|
|
116
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
117
|
-
try {
|
|
118
|
-
const res = await fetch(systemOne.endpoint, {
|
|
119
|
-
method: 'POST',
|
|
120
|
-
headers: { authorization: `Bearer ${systemOne.apiKey}`, 'content-type': 'application/json' },
|
|
121
|
-
body,
|
|
122
|
-
signal: opts.signal,
|
|
123
|
-
});
|
|
124
|
-
if (!res.ok) {
|
|
125
|
-
if (!RETRYABLE.has(res.status) || attempt === attempts - 1) {
|
|
126
|
-
throw new Error(`System One ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
127
|
-
}
|
|
128
|
-
const retryAfter = Number(res.headers.get('retry-after'));
|
|
129
|
-
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
|
|
130
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
const json = (await res.json());
|
|
134
|
-
const answers = {};
|
|
135
|
-
for (const [key, a] of Object.entries(json.answers ?? {})) {
|
|
136
|
-
answers[key] = normalizeAnswer(a);
|
|
137
|
-
}
|
|
138
|
-
return { answers, model: json.model ?? systemOne.model, inputTokens: json.usage?.input_tokens ?? 0 };
|
|
139
|
-
}
|
|
140
|
-
catch (err) {
|
|
141
|
-
if (err?.name === 'AbortError')
|
|
142
|
-
throw err;
|
|
143
|
-
lastError = err;
|
|
144
|
-
if (attempt === attempts - 1)
|
|
145
|
-
break;
|
|
146
|
-
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
throw lastError ?? new Error('System One request failed');
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Collapse any answer type to one 0–1 number so a mixed question set can share a
|
|
153
|
-
* threshold. A Noul is already a probability. A Choice reports the probability
|
|
154
|
-
* of the option it picked, and a Score its position across the levels — both
|
|
155
|
-
* keep the original answer alongside, because the number alone loses which
|
|
156
|
-
* option was chosen.
|
|
157
|
-
*/
|
|
158
|
-
function normalizeAnswer(a) {
|
|
159
|
-
if (a?.type === 'noul')
|
|
160
|
-
return { score: a.noul ?? 0, type: 'noul' };
|
|
161
|
-
if (a?.type === 'choice') {
|
|
162
|
-
const choice = a.choice;
|
|
163
|
-
return { score: a.probabilities?.[choice] ?? 0, type: 'choice', choice, confidence: a.confidence };
|
|
164
|
-
}
|
|
165
|
-
if (a?.type === 'score') {
|
|
166
|
-
const levels = a.probabilities ? Object.keys(a.probabilities).length : 0;
|
|
167
|
-
const raw = typeof a.score === 'number' ? a.score : 0;
|
|
168
|
-
return {
|
|
169
|
-
score: levels > 1 ? raw / (levels - 1) : raw,
|
|
170
|
-
type: 'score',
|
|
171
|
-
choice: String(a.score),
|
|
172
|
-
confidence: a.confidence,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
return { score: 0, type: a?.type ?? 'unknown' };
|
|
176
|
-
}
|
|
177
|
-
function thresholdFor(c, signal) {
|
|
178
|
-
if (c.threshold == null)
|
|
179
|
-
return null;
|
|
180
|
-
if (typeof c.threshold === 'number')
|
|
181
|
-
return c.threshold;
|
|
182
|
-
return c.threshold[signal] ?? null;
|
|
183
|
-
}
|
|
184
|
-
function labelFor(c, signal, score) {
|
|
185
|
-
if (typeof c.label === 'function')
|
|
186
|
-
return c.label(signal, score);
|
|
187
|
-
return c.label ?? 'spam';
|
|
188
|
-
}
|
|
189
|
-
const dbCtx = {
|
|
190
|
-
query: (sql, params) => querySQL(sql, params),
|
|
191
|
-
run: (sql, ...params) => runSQL(sql, params).then(() => undefined),
|
|
192
|
-
};
|
|
193
|
-
/** Every subject a classifier applies to, as `{uri, did, handle, value}`. */
|
|
194
|
-
async function enumerateSubjects(c) {
|
|
195
|
-
if (c.subject === 'account') {
|
|
196
|
-
const rows = (await querySQL(`SELECT did, handle FROM _repos WHERE status = 'active'`));
|
|
197
|
-
return rows.map((r) => ({ uri: r.did, did: r.did, handle: r.handle }));
|
|
198
|
-
}
|
|
199
|
-
const out = [];
|
|
200
|
-
for (const collection of c.collections ?? []) {
|
|
201
|
-
const schema = getSchema(collection);
|
|
202
|
-
if (!schema)
|
|
203
|
-
continue;
|
|
204
|
-
const rows = (await querySQL(`SELECT * FROM ${schema.tableName}`));
|
|
205
|
-
for (const row of rows) {
|
|
206
|
-
const value = {};
|
|
207
|
-
for (const col of schema.columns) {
|
|
208
|
-
let v = row[col.name];
|
|
209
|
-
if (v === null || v === undefined)
|
|
210
|
-
continue;
|
|
211
|
-
if (col.isJson && typeof v === 'string') {
|
|
212
|
-
try {
|
|
213
|
-
v = JSON.parse(v);
|
|
214
|
-
}
|
|
215
|
-
catch { }
|
|
216
|
-
}
|
|
217
|
-
value[col.originalName] = v;
|
|
218
|
-
}
|
|
219
|
-
out.push({ uri: row.uri, did: row.did, collection, value });
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return out;
|
|
223
|
-
}
|
|
224
|
-
let current = null;
|
|
225
|
-
/** Progress of the scan in flight, or the last one to finish. */
|
|
226
|
-
export function getScanProgress() {
|
|
227
|
-
return current;
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Score every subject a classifier applies to, storing results and filing a
|
|
231
|
-
* report for each crossing signal.
|
|
232
|
-
*
|
|
233
|
-
* Subjects whose state is unchanged since the last scan are skipped unless
|
|
234
|
-
* `force` is set, so a repeat scan costs only what actually moved.
|
|
235
|
-
*/
|
|
236
|
-
export async function runScan(opts = {}) {
|
|
237
|
-
if (current?.running)
|
|
238
|
-
throw new Error('A scan is already running');
|
|
239
|
-
const selected = opts.classifier ? classifiers.filter((c) => c.name === opts.classifier) : classifiers;
|
|
240
|
-
if (!selected.length)
|
|
241
|
-
throw new Error(opts.classifier ? `No classifier named ${opts.classifier}` : 'No classifiers');
|
|
242
|
-
// Only the classifiers that actually ask a model need one configured. A
|
|
243
|
-
// host-local classifier runs with no API key at all.
|
|
244
|
-
if (selected.some((c) => !c.classify) && !isSystemOneConfigured())
|
|
245
|
-
throw new Error('System One is not configured');
|
|
246
|
-
const progress = {
|
|
247
|
-
scanned: 0,
|
|
248
|
-
skipped: 0,
|
|
249
|
-
scored: 0,
|
|
250
|
-
filed: 0,
|
|
251
|
-
errors: 0,
|
|
252
|
-
inputTokens: 0,
|
|
253
|
-
running: true,
|
|
254
|
-
startedAt: new Date().toISOString(),
|
|
255
|
-
};
|
|
256
|
-
current = progress;
|
|
257
|
-
try {
|
|
258
|
-
for (const c of selected) {
|
|
259
|
-
const subjects = await enumerateSubjects(c);
|
|
260
|
-
const seen = opts.force ? new Map() : await getClassificationFingerprints(c.name);
|
|
261
|
-
// The questions are part of what produced a score, so the fingerprint
|
|
262
|
-
// covers them as well as the state: rewording a classifier's criteria
|
|
263
|
-
// invalidates every score written under the old wording.
|
|
264
|
-
const questionsHash = createHash('sha256')
|
|
265
|
-
.update(JSON.stringify(c.questions ?? c.classify?.toString() ?? ''))
|
|
266
|
-
.digest('hex')
|
|
267
|
-
.slice(0, 16);
|
|
268
|
-
const queue = opts.limit ? subjects.slice(0, opts.limit) : subjects;
|
|
269
|
-
const concurrency = opts.concurrency ?? 8;
|
|
270
|
-
let cursor = 0;
|
|
271
|
-
const worker = async () => {
|
|
272
|
-
while (cursor < queue.length) {
|
|
273
|
-
if (opts.signal?.aborted)
|
|
274
|
-
return;
|
|
275
|
-
const subject = queue[cursor++];
|
|
276
|
-
progress.scanned++;
|
|
277
|
-
try {
|
|
278
|
-
const state = await c.buildState({ db: dbCtx, subject });
|
|
279
|
-
if (!state) {
|
|
280
|
-
progress.skipped++;
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
const fingerprint = createHash('sha256')
|
|
284
|
-
.update(questionsHash)
|
|
285
|
-
.update(JSON.stringify(state))
|
|
286
|
-
.digest('hex')
|
|
287
|
-
.slice(0, 32);
|
|
288
|
-
if (seen.get(subject.uri) === fingerprint) {
|
|
289
|
-
progress.skipped++;
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
let answers;
|
|
293
|
-
let model;
|
|
294
|
-
if (c.classify) {
|
|
295
|
-
const raw = await c.classify(state, { db: dbCtx, subject });
|
|
296
|
-
answers = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, typeof v === 'number' ? { score: v, type: 'local' } : v]));
|
|
297
|
-
model = `local:${c.name}`;
|
|
298
|
-
}
|
|
299
|
-
else {
|
|
300
|
-
const res = await askSystemOne(state, c.questions, { signal: opts.signal });
|
|
301
|
-
answers = res.answers;
|
|
302
|
-
model = res.model;
|
|
303
|
-
progress.inputTokens += res.inputTokens;
|
|
304
|
-
}
|
|
305
|
-
let topSignal = null;
|
|
306
|
-
let topScore = 0;
|
|
307
|
-
for (const [name, sig] of Object.entries(answers)) {
|
|
308
|
-
if (sig.score > topScore) {
|
|
309
|
-
topScore = sig.score;
|
|
310
|
-
topSignal = name;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
await upsertClassification({
|
|
314
|
-
subjectUri: subject.uri,
|
|
315
|
-
subjectDid: subject.did,
|
|
316
|
-
classifier: c.name,
|
|
317
|
-
signals: answers,
|
|
318
|
-
state,
|
|
319
|
-
topSignal,
|
|
320
|
-
topScore,
|
|
321
|
-
fingerprint,
|
|
322
|
-
model,
|
|
323
|
-
});
|
|
324
|
-
progress.scored++;
|
|
325
|
-
// File on the highest-scoring signal that crosses its own threshold —
|
|
326
|
-
// a second report for the same subject would only split the review.
|
|
327
|
-
let crossing = null;
|
|
328
|
-
for (const [name, sig] of Object.entries(answers)) {
|
|
329
|
-
const t = thresholdFor(c, name);
|
|
330
|
-
if (t != null && sig.score >= t && (!crossing || sig.score > crossing.score)) {
|
|
331
|
-
crossing = { signal: name, score: sig.score };
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (crossing) {
|
|
335
|
-
const label = labelFor(c, crossing.signal, crossing.score);
|
|
336
|
-
if (!(await findOpenReport(subject.uri, label))) {
|
|
337
|
-
await insertReport({
|
|
338
|
-
subjectUri: subject.uri,
|
|
339
|
-
subjectDid: subject.did,
|
|
340
|
-
label,
|
|
341
|
-
reason: `${c.name}: ${crossing.signal} ${crossing.score.toFixed(2)}`,
|
|
342
|
-
reportedBy: `system:${c.name}`,
|
|
343
|
-
});
|
|
344
|
-
progress.filed++;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
catch (err) {
|
|
349
|
-
if (err?.name === 'AbortError')
|
|
350
|
-
return;
|
|
351
|
-
progress.errors++;
|
|
352
|
-
emit('classifiers', 'scan_error', { classifier: c.name, subject: subject.uri, error: err.message });
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
};
|
|
356
|
-
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
catch (err) {
|
|
360
|
-
progress.error = err.message;
|
|
361
|
-
throw err;
|
|
362
|
-
}
|
|
363
|
-
finally {
|
|
364
|
-
progress.running = false;
|
|
365
|
-
progress.finishedAt = new Date().toISOString();
|
|
366
|
-
log(`[classifiers] scan: ${progress.scored} scored, ${progress.skipped} skipped, ` +
|
|
367
|
-
`${progress.filed} filed, ${progress.errors} errors, ${progress.inputTokens} tokens`);
|
|
368
|
-
}
|
|
369
|
-
return progress;
|
|
370
|
-
}
|