@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/dist/classifiers.d.ts +191 -0
- package/dist/classifiers.d.ts.map +1 -0
- package/dist/classifiers.js +424 -0
- package/dist/cli.js +1 -0
- package/dist/config.d.ts +17 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -0
- package/dist/database/db.d.ts +64 -0
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +231 -0
- package/dist/main.js +8 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +90 -2
- package/package.json +3 -2
- package/public/admin.html +475 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/** A typed question in the shape Clef 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 Clef.
|
|
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
|
+
* URLs of images to show the model alongside `state`, at most four. hatk
|
|
48
|
+
* fetches each and embeds it in the request, since Clef takes images inline
|
|
49
|
+
* rather than by reference.
|
|
50
|
+
*
|
|
51
|
+
* Return small renditions. Clef's context window is counted on the encoded
|
|
52
|
+
* bytes, so a full-size photo can overflow it where a 512px one never does.
|
|
53
|
+
* The state should carry whatever identifies the image — a blob CID — so
|
|
54
|
+
* that a changed image changes the fingerprint and is scored again.
|
|
55
|
+
*/
|
|
56
|
+
images?: (state: Record<string, unknown>, ctx: ClassifierContext) => Promise<string[]> | string[];
|
|
57
|
+
/**
|
|
58
|
+
* Score at or above which a signal files a report. A number applies to every
|
|
59
|
+
* question; a map sets it per question. Questions absent from the map never
|
|
60
|
+
* file, though their scores are still stored and shown.
|
|
61
|
+
*/
|
|
62
|
+
threshold?: number | Record<string, number>;
|
|
63
|
+
/** Label the filed report carries. A function receives the crossing signal. */
|
|
64
|
+
label?: string | ((signal: string, score: number) => string);
|
|
65
|
+
}
|
|
66
|
+
export declare function defineClassifier(module: ClassifierModule): {
|
|
67
|
+
/** `account` scans every indexed repo; `record` scans rows of `collections`. */
|
|
68
|
+
subject: "account" | "record";
|
|
69
|
+
/** Collections to scan. Required when `subject` is `record`. */
|
|
70
|
+
collections?: string[];
|
|
71
|
+
/** The questions to ask. Answers come back under the same keys. Omit when
|
|
72
|
+
* the classifier supplies its own `classify`. */
|
|
73
|
+
questions?: Record<string, ClassifierQuestion>;
|
|
74
|
+
/**
|
|
75
|
+
* Produce signals without asking Clef.
|
|
76
|
+
*
|
|
77
|
+
* A classifier that runs its own model — an image classifier on the host, a
|
|
78
|
+
* hash lookup, a heuristic — sets this instead of `questions`. Everything
|
|
79
|
+
* downstream is unchanged: the same thresholds, the same report, the same
|
|
80
|
+
* review card. Return a bare number for a plain score.
|
|
81
|
+
*/
|
|
82
|
+
classify?: (state: Record<string, unknown>, ctx: ClassifierContext) => Promise<Record<string, number | Signal>>;
|
|
83
|
+
/**
|
|
84
|
+
* Build the model `state` for this subject. Return `null` to skip it — a
|
|
85
|
+
* skipped subject costs no tokens. Gate on whatever makes the question
|
|
86
|
+
* answerable: an account with no content cannot be judged on its content.
|
|
87
|
+
*/
|
|
88
|
+
buildState: (ctx: ClassifierContext) => Promise<Record<string, unknown> | null>;
|
|
89
|
+
/**
|
|
90
|
+
* URLs of images to show the model alongside `state`, at most four. hatk
|
|
91
|
+
* fetches each and embeds it in the request, since Clef takes images inline
|
|
92
|
+
* rather than by reference.
|
|
93
|
+
*
|
|
94
|
+
* Return small renditions. Clef's context window is counted on the encoded
|
|
95
|
+
* bytes, so a full-size photo can overflow it where a 512px one never does.
|
|
96
|
+
* The state should carry whatever identifies the image — a blob CID — so
|
|
97
|
+
* that a changed image changes the fingerprint and is scored again.
|
|
98
|
+
*/
|
|
99
|
+
images?: (state: Record<string, unknown>, ctx: ClassifierContext) => Promise<string[]> | string[];
|
|
100
|
+
/**
|
|
101
|
+
* Score at or above which a signal files a report. A number applies to every
|
|
102
|
+
* question; a map sets it per question. Questions absent from the map never
|
|
103
|
+
* file, though their scores are still stored and shown.
|
|
104
|
+
*/
|
|
105
|
+
threshold?: number | Record<string, number>;
|
|
106
|
+
/** Label the filed report carries. A function receives the crossing signal. */
|
|
107
|
+
label?: string | ((signal: string, score: number) => string);
|
|
108
|
+
__type: "classifiers";
|
|
109
|
+
};
|
|
110
|
+
interface LoadedClassifier extends ClassifierModule {
|
|
111
|
+
name: string;
|
|
112
|
+
}
|
|
113
|
+
/** Discover and load classifier modules from the `classifiers/` directory. */
|
|
114
|
+
export declare function initClassifiers(classifiersDir: string): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* Register a classifier directly, bypassing directory discovery.
|
|
117
|
+
*
|
|
118
|
+
* Mirrors {@link registerLabelModule}: tests and embedders that build their own
|
|
119
|
+
* module graph have no `classifiers/` directory to read.
|
|
120
|
+
*/
|
|
121
|
+
export declare function registerClassifier(name: string, module: ClassifierModule): void;
|
|
122
|
+
/** Clear all registered classifiers (for hot-reload). */
|
|
123
|
+
export declare function clearClassifiers(): void;
|
|
124
|
+
/** Names of all loaded classifiers. */
|
|
125
|
+
export declare function listClassifiers(): string[];
|
|
126
|
+
export declare function getClassifier(name: string): LoadedClassifier | undefined;
|
|
127
|
+
export interface ClefClientConfig {
|
|
128
|
+
apiToken: string;
|
|
129
|
+
model: string;
|
|
130
|
+
endpoint: string;
|
|
131
|
+
}
|
|
132
|
+
export declare function configureClef(config: ClefClientConfig | null): void;
|
|
133
|
+
export declare function isClefConfigured(): boolean;
|
|
134
|
+
/** A single answer, normalized to a 0–1 score regardless of question type. */
|
|
135
|
+
export interface Signal {
|
|
136
|
+
score: number;
|
|
137
|
+
type: string;
|
|
138
|
+
/** Choice/Score only: the selected option and its confidence. */
|
|
139
|
+
choice?: string;
|
|
140
|
+
confidence?: number;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Ask Clef one request's worth of questions about one state.
|
|
144
|
+
*
|
|
145
|
+
* Retries on 429 and 5xx, honoring `retry-after` when the response carries one.
|
|
146
|
+
* The caller's concurrency is what keeps us under the rate limit; this only
|
|
147
|
+
* recovers the requests that still slip past it.
|
|
148
|
+
*/
|
|
149
|
+
export declare function askClef(state: Record<string, unknown>, questions: Record<string, ClassifierQuestion>, opts?: {
|
|
150
|
+
images?: string[];
|
|
151
|
+
attempts?: number;
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
}): Promise<{
|
|
154
|
+
answers: Record<string, Signal>;
|
|
155
|
+
model: string;
|
|
156
|
+
inputTokens: number;
|
|
157
|
+
}>;
|
|
158
|
+
export interface ScanProgress {
|
|
159
|
+
/** Subjects considered. */
|
|
160
|
+
scanned: number;
|
|
161
|
+
/** Gated out by `buildState` returning null, or unchanged since the last scan. */
|
|
162
|
+
skipped: number;
|
|
163
|
+
/** Sent to the model and stored. */
|
|
164
|
+
scored: number;
|
|
165
|
+
/** Reports filed for crossing a threshold. */
|
|
166
|
+
filed: number;
|
|
167
|
+
errors: number;
|
|
168
|
+
inputTokens: number;
|
|
169
|
+
running: boolean;
|
|
170
|
+
startedAt: string;
|
|
171
|
+
finishedAt?: string;
|
|
172
|
+
error?: string;
|
|
173
|
+
}
|
|
174
|
+
/** Progress of the scan in flight, or the last one to finish. */
|
|
175
|
+
export declare function getScanProgress(): ScanProgress | null;
|
|
176
|
+
/**
|
|
177
|
+
* Score every subject a classifier applies to, storing results and filing a
|
|
178
|
+
* report for each crossing signal.
|
|
179
|
+
*
|
|
180
|
+
* Subjects whose state is unchanged since the last scan are skipped unless
|
|
181
|
+
* `force` is set, so a repeat scan costs only what actually moved.
|
|
182
|
+
*/
|
|
183
|
+
export declare function runScan(opts?: {
|
|
184
|
+
classifier?: string;
|
|
185
|
+
force?: boolean;
|
|
186
|
+
limit?: number;
|
|
187
|
+
concurrency?: number;
|
|
188
|
+
signal?: AbortSignal;
|
|
189
|
+
}): Promise<ScanProgress>;
|
|
190
|
+
export {};
|
|
191
|
+
//# sourceMappingURL=classifiers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classifiers.d.ts","sourceRoot":"","sources":["../src/classifiers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,424 @@
|
|
|
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 Clef, Cloudflare's
|
|
11
|
+
* decision model on Workers AI.
|
|
12
|
+
*
|
|
13
|
+
* A classifier is the moderation counterpart to a label rule: where a label rule
|
|
14
|
+
* decides from the record alone, a classifier asks a model a set of narrow typed
|
|
15
|
+
* questions and stores the probabilities. Nothing acts on a score — crossing a
|
|
16
|
+
* threshold files a report into the same `_reports` queue a user report lands in,
|
|
17
|
+
* so a person still makes every enforcement decision.
|
|
18
|
+
*
|
|
19
|
+
* Place classifier modules in the `classifiers/` directory.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* // classifiers/impersonation.ts
|
|
24
|
+
* import { defineClassifier } from '$hatk'
|
|
25
|
+
*
|
|
26
|
+
* export default defineClassifier({
|
|
27
|
+
* subject: 'account',
|
|
28
|
+
* label: 'impersonation',
|
|
29
|
+
* threshold: 0.7,
|
|
30
|
+
* questions: {
|
|
31
|
+
* passing_as: {
|
|
32
|
+
* type: 'noul',
|
|
33
|
+
* instructions: 'Is this account trying to be mistaken for a famous person?',
|
|
34
|
+
* },
|
|
35
|
+
* },
|
|
36
|
+
* async buildState({ subject, db }) {
|
|
37
|
+
* const [p] = await db.query(`SELECT description FROM profile WHERE did = $1`, [subject.did])
|
|
38
|
+
* return { handle: subject.handle, bio: p?.description }
|
|
39
|
+
* },
|
|
40
|
+
* })
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
import { resolve } from 'node:path';
|
|
44
|
+
import { readdirSync } from 'node:fs';
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
import { querySQL, runSQL, getSchema, upsertClassification, getClassificationFingerprints, findOpenReport, insertReport, } from "./database/db.js";
|
|
47
|
+
import { log, emit } from "./logger.js";
|
|
48
|
+
export function defineClassifier(module) {
|
|
49
|
+
return { __type: 'classifiers', ...module };
|
|
50
|
+
}
|
|
51
|
+
const classifiers = [];
|
|
52
|
+
/** Discover and load classifier modules from the `classifiers/` directory. */
|
|
53
|
+
export async function initClassifiers(classifiersDir) {
|
|
54
|
+
let files;
|
|
55
|
+
try {
|
|
56
|
+
files = readdirSync(classifiersDir)
|
|
57
|
+
.filter((f) => (f.endsWith('.ts') || f.endsWith('.js')) && !f.startsWith('_'))
|
|
58
|
+
.sort();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const name = file.replace(/\.(ts|js)$/, '');
|
|
65
|
+
const scriptPath = resolve(classifiersDir, file);
|
|
66
|
+
const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`));
|
|
67
|
+
const handler = mod.default;
|
|
68
|
+
if (!handler?.buildState || !(handler.questions || handler.classify)) {
|
|
69
|
+
log(`[classifiers] skipped ${name}: needs buildState plus questions or classify`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
classifiers.push({ name, ...handler });
|
|
73
|
+
log(`[classifiers] discovered: ${name} (${handler.subject})`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Register a classifier directly, bypassing directory discovery.
|
|
78
|
+
*
|
|
79
|
+
* Mirrors {@link registerLabelModule}: tests and embedders that build their own
|
|
80
|
+
* module graph have no `classifiers/` directory to read.
|
|
81
|
+
*/
|
|
82
|
+
export function registerClassifier(name, module) {
|
|
83
|
+
classifiers.push({ name, ...module });
|
|
84
|
+
}
|
|
85
|
+
/** Clear all registered classifiers (for hot-reload). */
|
|
86
|
+
export function clearClassifiers() {
|
|
87
|
+
classifiers.length = 0;
|
|
88
|
+
}
|
|
89
|
+
/** Names of all loaded classifiers. */
|
|
90
|
+
export function listClassifiers() {
|
|
91
|
+
return classifiers.map((c) => c.name);
|
|
92
|
+
}
|
|
93
|
+
export function getClassifier(name) {
|
|
94
|
+
return classifiers.find((c) => c.name === name);
|
|
95
|
+
}
|
|
96
|
+
let clef = null;
|
|
97
|
+
export function configureClef(config) {
|
|
98
|
+
clef = config;
|
|
99
|
+
}
|
|
100
|
+
export function isClefConfigured() {
|
|
101
|
+
return clef !== null;
|
|
102
|
+
}
|
|
103
|
+
/** Clef's ceiling on images per request. */
|
|
104
|
+
const MAX_IMAGES = 4;
|
|
105
|
+
/**
|
|
106
|
+
* The longest side an image is sent at. Clef counts its context on the encoded
|
|
107
|
+
* bytes: a 1000px feed thumbnail of 200KB or more overflows the 65k window,
|
|
108
|
+
* while at 512px every image measured fit in about a thousand tokens.
|
|
109
|
+
*/
|
|
110
|
+
const IMAGE_EDGE = 512;
|
|
111
|
+
let sharpModule;
|
|
112
|
+
function loadSharp() {
|
|
113
|
+
sharpModule ??= import('sharp')
|
|
114
|
+
.then((m) => m.default)
|
|
115
|
+
.catch(() => {
|
|
116
|
+
emit('classifiers', 'resize_unavailable', { reason: 'sharp is not installed' });
|
|
117
|
+
return null;
|
|
118
|
+
});
|
|
119
|
+
return sharpModule;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Fetch an image and embed it as the base64 data URI Clef requires, scaled
|
|
123
|
+
* down to {@link IMAGE_EDGE}. Without sharp the original is sent, and an image
|
|
124
|
+
* too large for the context window fails that one subject.
|
|
125
|
+
*/
|
|
126
|
+
async function embedImage(url, signal) {
|
|
127
|
+
const res = await fetch(url, { signal });
|
|
128
|
+
if (!res.ok)
|
|
129
|
+
throw new Error(`image ${res.status}: ${url}`);
|
|
130
|
+
const type = (res.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
131
|
+
if (!/^image\/(png|jpeg|webp)$/.test(type))
|
|
132
|
+
throw new Error(`image ${url} is ${type || 'untyped'}, not PNG, JPEG or WebP`);
|
|
133
|
+
const source = Buffer.from(await res.arrayBuffer());
|
|
134
|
+
const sharp = await loadSharp();
|
|
135
|
+
if (!sharp)
|
|
136
|
+
return `data:${type};base64,${source.toString('base64')}`;
|
|
137
|
+
const scaled = await sharp(source)
|
|
138
|
+
.rotate()
|
|
139
|
+
.resize({ width: IMAGE_EDGE, height: IMAGE_EDGE, fit: 'inside', withoutEnlargement: true })
|
|
140
|
+
.jpeg({ quality: 80 })
|
|
141
|
+
.toBuffer();
|
|
142
|
+
return `data:image/jpeg;base64,${scaled.toString('base64')}`;
|
|
143
|
+
}
|
|
144
|
+
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
|
|
145
|
+
/** A response that a retry would only repeat: a rejected request, or the last attempt. */
|
|
146
|
+
class FatalClefError extends Error {
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Ask Clef one request's worth of questions about one state.
|
|
150
|
+
*
|
|
151
|
+
* Retries on 429 and 5xx, honoring `retry-after` when the response carries one.
|
|
152
|
+
* The caller's concurrency is what keeps us under the rate limit; this only
|
|
153
|
+
* recovers the requests that still slip past it.
|
|
154
|
+
*/
|
|
155
|
+
export async function askClef(state, questions, opts = {}) {
|
|
156
|
+
if (!clef)
|
|
157
|
+
throw new Error('Clef is not configured — set `clef` in hatk.config.ts');
|
|
158
|
+
const attempts = opts.attempts ?? 4;
|
|
159
|
+
const images = opts.images?.length ? opts.images : undefined;
|
|
160
|
+
const body = JSON.stringify({ state, model: clef.model, questions, ...(images ? { images } : {}) });
|
|
161
|
+
let lastError = null;
|
|
162
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(clef.endpoint, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { authorization: `Bearer ${clef.apiToken}`, 'content-type': 'application/json' },
|
|
167
|
+
body,
|
|
168
|
+
signal: opts.signal,
|
|
169
|
+
});
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
if (!RETRYABLE.has(res.status) || attempt === attempts - 1) {
|
|
172
|
+
throw new FatalClefError(`Clef ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
173
|
+
}
|
|
174
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
175
|
+
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
|
|
176
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Workers AI wraps the model's output in `{ result, success, errors }`.
|
|
180
|
+
const json = (await res.json());
|
|
181
|
+
if (json.success === false)
|
|
182
|
+
throw new FatalClefError(`Clef: ${JSON.stringify(json.errors).slice(0, 200)}`);
|
|
183
|
+
const out = json.result ?? json;
|
|
184
|
+
const answers = {};
|
|
185
|
+
for (const [key, a] of Object.entries(out.answers ?? {})) {
|
|
186
|
+
answers[key] = normalizeAnswer(a);
|
|
187
|
+
}
|
|
188
|
+
return { answers, model: out.model ?? clef.model, inputTokens: out.usage?.input_tokens ?? 0 };
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
if (err?.name === 'AbortError' || err instanceof FatalClefError)
|
|
192
|
+
throw err;
|
|
193
|
+
lastError = err;
|
|
194
|
+
if (attempt === attempts - 1)
|
|
195
|
+
break;
|
|
196
|
+
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw lastError ?? new Error('Clef request failed');
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Collapse any answer type to one 0–1 number so a mixed question set can share a
|
|
203
|
+
* threshold. A Noul is already a probability. A Choice reports the probability
|
|
204
|
+
* of the option it picked, and a Score its position across the levels — both
|
|
205
|
+
* keep the original answer alongside, because the number alone loses which
|
|
206
|
+
* option was chosen.
|
|
207
|
+
*/
|
|
208
|
+
function normalizeAnswer(a) {
|
|
209
|
+
if (a?.type === 'noul')
|
|
210
|
+
return { score: a.noul ?? 0, type: 'noul' };
|
|
211
|
+
if (a?.type === 'choice') {
|
|
212
|
+
const choice = a.choice;
|
|
213
|
+
return { score: a.probabilities?.[choice] ?? 0, type: 'choice', choice, confidence: a.confidence };
|
|
214
|
+
}
|
|
215
|
+
if (a?.type === 'score') {
|
|
216
|
+
const levels = a.probabilities ? Object.keys(a.probabilities).length : 0;
|
|
217
|
+
const raw = typeof a.score === 'number' ? a.score : 0;
|
|
218
|
+
return {
|
|
219
|
+
score: levels > 1 ? raw / (levels - 1) : raw,
|
|
220
|
+
type: 'score',
|
|
221
|
+
choice: String(a.score),
|
|
222
|
+
confidence: a.confidence,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { score: 0, type: a?.type ?? 'unknown' };
|
|
226
|
+
}
|
|
227
|
+
function thresholdFor(c, signal) {
|
|
228
|
+
if (c.threshold == null)
|
|
229
|
+
return null;
|
|
230
|
+
if (typeof c.threshold === 'number')
|
|
231
|
+
return c.threshold;
|
|
232
|
+
return c.threshold[signal] ?? null;
|
|
233
|
+
}
|
|
234
|
+
function labelFor(c, signal, score) {
|
|
235
|
+
if (typeof c.label === 'function')
|
|
236
|
+
return c.label(signal, score);
|
|
237
|
+
return c.label ?? 'spam';
|
|
238
|
+
}
|
|
239
|
+
const dbCtx = {
|
|
240
|
+
query: (sql, params) => querySQL(sql, params),
|
|
241
|
+
run: (sql, ...params) => runSQL(sql, params).then(() => undefined),
|
|
242
|
+
};
|
|
243
|
+
/** Every subject a classifier applies to, as `{uri, did, handle, value}`. */
|
|
244
|
+
async function enumerateSubjects(c) {
|
|
245
|
+
if (c.subject === 'account') {
|
|
246
|
+
const rows = (await querySQL(`SELECT did, handle FROM _repos WHERE status = 'active'`));
|
|
247
|
+
return rows.map((r) => ({ uri: r.did, did: r.did, handle: r.handle }));
|
|
248
|
+
}
|
|
249
|
+
const out = [];
|
|
250
|
+
for (const collection of c.collections ?? []) {
|
|
251
|
+
const schema = getSchema(collection);
|
|
252
|
+
if (!schema)
|
|
253
|
+
continue;
|
|
254
|
+
const rows = (await querySQL(`SELECT * FROM ${schema.tableName}`));
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
const value = {};
|
|
257
|
+
for (const col of schema.columns) {
|
|
258
|
+
let v = row[col.name];
|
|
259
|
+
if (v === null || v === undefined)
|
|
260
|
+
continue;
|
|
261
|
+
if (col.isJson && typeof v === 'string') {
|
|
262
|
+
try {
|
|
263
|
+
v = JSON.parse(v);
|
|
264
|
+
}
|
|
265
|
+
catch { }
|
|
266
|
+
}
|
|
267
|
+
value[col.originalName] = v;
|
|
268
|
+
}
|
|
269
|
+
out.push({ uri: row.uri, did: row.did, collection, value });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
let current = null;
|
|
275
|
+
/** Progress of the scan in flight, or the last one to finish. */
|
|
276
|
+
export function getScanProgress() {
|
|
277
|
+
return current;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Score every subject a classifier applies to, storing results and filing a
|
|
281
|
+
* report for each crossing signal.
|
|
282
|
+
*
|
|
283
|
+
* Subjects whose state is unchanged since the last scan are skipped unless
|
|
284
|
+
* `force` is set, so a repeat scan costs only what actually moved.
|
|
285
|
+
*/
|
|
286
|
+
export async function runScan(opts = {}) {
|
|
287
|
+
if (current?.running)
|
|
288
|
+
throw new Error('A scan is already running');
|
|
289
|
+
const selected = opts.classifier ? classifiers.filter((c) => c.name === opts.classifier) : classifiers;
|
|
290
|
+
if (!selected.length)
|
|
291
|
+
throw new Error(opts.classifier ? `No classifier named ${opts.classifier}` : 'No classifiers');
|
|
292
|
+
// Only the classifiers that actually ask Clef need it configured. A
|
|
293
|
+
// host-local classifier runs with no API token at all.
|
|
294
|
+
if (selected.some((c) => !c.classify) && !isClefConfigured())
|
|
295
|
+
throw new Error('Clef is not configured');
|
|
296
|
+
const progress = {
|
|
297
|
+
scanned: 0,
|
|
298
|
+
skipped: 0,
|
|
299
|
+
scored: 0,
|
|
300
|
+
filed: 0,
|
|
301
|
+
errors: 0,
|
|
302
|
+
inputTokens: 0,
|
|
303
|
+
running: true,
|
|
304
|
+
startedAt: new Date().toISOString(),
|
|
305
|
+
};
|
|
306
|
+
current = progress;
|
|
307
|
+
try {
|
|
308
|
+
for (const c of selected) {
|
|
309
|
+
const subjects = await enumerateSubjects(c);
|
|
310
|
+
const seen = opts.force ? new Map() : await getClassificationFingerprints(c.name);
|
|
311
|
+
// The questions are part of what produced a score, so the fingerprint
|
|
312
|
+
// covers them as well as the state: rewording a classifier's criteria,
|
|
313
|
+
// or changing which images it sends, invalidates every score written
|
|
314
|
+
// under the old version.
|
|
315
|
+
const questionsHash = createHash('sha256')
|
|
316
|
+
.update(JSON.stringify(c.questions ?? c.classify?.toString() ?? ''))
|
|
317
|
+
.update(c.images?.toString() ?? '')
|
|
318
|
+
.digest('hex')
|
|
319
|
+
.slice(0, 16);
|
|
320
|
+
const queue = opts.limit ? subjects.slice(0, opts.limit) : subjects;
|
|
321
|
+
const concurrency = opts.concurrency ?? 8;
|
|
322
|
+
let cursor = 0;
|
|
323
|
+
const worker = async () => {
|
|
324
|
+
while (cursor < queue.length) {
|
|
325
|
+
if (opts.signal?.aborted)
|
|
326
|
+
return;
|
|
327
|
+
const subject = queue[cursor++];
|
|
328
|
+
progress.scanned++;
|
|
329
|
+
try {
|
|
330
|
+
const state = await c.buildState({ db: dbCtx, subject });
|
|
331
|
+
if (!state) {
|
|
332
|
+
progress.skipped++;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const fingerprint = createHash('sha256')
|
|
336
|
+
.update(questionsHash)
|
|
337
|
+
.update(JSON.stringify(state))
|
|
338
|
+
.digest('hex')
|
|
339
|
+
.slice(0, 32);
|
|
340
|
+
if (seen.get(subject.uri) === fingerprint) {
|
|
341
|
+
progress.skipped++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
let answers;
|
|
345
|
+
let model;
|
|
346
|
+
if (c.classify) {
|
|
347
|
+
const raw = await c.classify(state, { db: dbCtx, subject });
|
|
348
|
+
answers = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, typeof v === 'number' ? { score: v, type: 'local' } : v]));
|
|
349
|
+
model = `local:${c.name}`;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const urls = c.images ? (await c.images(state, { db: dbCtx, subject })).slice(0, MAX_IMAGES) : [];
|
|
353
|
+
const images = await Promise.all(urls.map((u) => embedImage(u, opts.signal)));
|
|
354
|
+
const res = await askClef(state, c.questions, { images, signal: opts.signal });
|
|
355
|
+
answers = res.answers;
|
|
356
|
+
model = res.model;
|
|
357
|
+
progress.inputTokens += res.inputTokens;
|
|
358
|
+
}
|
|
359
|
+
let topSignal = null;
|
|
360
|
+
let topScore = 0;
|
|
361
|
+
for (const [name, sig] of Object.entries(answers)) {
|
|
362
|
+
if (sig.score > topScore) {
|
|
363
|
+
topScore = sig.score;
|
|
364
|
+
topSignal = name;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
await upsertClassification({
|
|
368
|
+
subjectUri: subject.uri,
|
|
369
|
+
subjectDid: subject.did,
|
|
370
|
+
classifier: c.name,
|
|
371
|
+
signals: answers,
|
|
372
|
+
state,
|
|
373
|
+
topSignal,
|
|
374
|
+
topScore,
|
|
375
|
+
fingerprint,
|
|
376
|
+
model,
|
|
377
|
+
});
|
|
378
|
+
progress.scored++;
|
|
379
|
+
// File on the highest-scoring signal that crosses its own threshold —
|
|
380
|
+
// a second report for the same subject would only split the review.
|
|
381
|
+
let crossing = null;
|
|
382
|
+
for (const [name, sig] of Object.entries(answers)) {
|
|
383
|
+
const t = thresholdFor(c, name);
|
|
384
|
+
if (t != null && sig.score >= t && (!crossing || sig.score > crossing.score)) {
|
|
385
|
+
crossing = { signal: name, score: sig.score };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (crossing) {
|
|
389
|
+
const label = labelFor(c, crossing.signal, crossing.score);
|
|
390
|
+
if (!(await findOpenReport(subject.uri, label))) {
|
|
391
|
+
await insertReport({
|
|
392
|
+
subjectUri: subject.uri,
|
|
393
|
+
subjectDid: subject.did,
|
|
394
|
+
label,
|
|
395
|
+
reason: `${c.name}: ${crossing.signal} ${crossing.score.toFixed(2)}`,
|
|
396
|
+
reportedBy: `system:${c.name}`,
|
|
397
|
+
});
|
|
398
|
+
progress.filed++;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
if (err?.name === 'AbortError')
|
|
404
|
+
return;
|
|
405
|
+
progress.errors++;
|
|
406
|
+
emit('classifiers', 'scan_error', { classifier: c.name, subject: subject.uri, error: err.message });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
progress.error = err.message;
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
progress.running = false;
|
|
419
|
+
progress.finishedAt = new Date().toISOString();
|
|
420
|
+
log(`[classifiers] scan: ${progress.scored} scored, ${progress.skipped} skipped, ` +
|
|
421
|
+
`${progress.filed} filed, ${progress.errors} errors, ${progress.inputTokens} tokens`);
|
|
422
|
+
}
|
|
423
|
+
return progress;
|
|
424
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -443,6 +443,7 @@ else if (command === 'generate') {
|
|
|
443
443
|
out += `export { defineSetup } from '@hatk/hatk/setup'\n`;
|
|
444
444
|
out += `export { defineHook } from '@hatk/hatk/hooks'\n`;
|
|
445
445
|
out += `export { defineLabel } from '@hatk/hatk/labels'\n`;
|
|
446
|
+
out += `export { defineClassifier } from '@hatk/hatk/classifiers'\n`;
|
|
446
447
|
out += `export { defineOG } from '@hatk/hatk/opengraph'\n`;
|
|
447
448
|
out += `export { defineRenderer } from '@hatk/hatk/renderer'\n`;
|
|
448
449
|
out += `export type Ctx<K extends keyof XrpcSchema & keyof Registry> = XrpcContext<\n`;
|
package/dist/config.d.ts
CHANGED
|
@@ -171,9 +171,24 @@ export interface HatkConfig {
|
|
|
171
171
|
push: PushConfig | null;
|
|
172
172
|
admins: string[];
|
|
173
173
|
spaces: SpacesConfig | null;
|
|
174
|
+
clef: ClefConfig | null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Clef access for the classifier system, through Workers AI. Without it,
|
|
178
|
+
* classifiers load but no scan can run. The account and token are read from
|
|
179
|
+
* `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_AI_TOKEN` in preference to the config
|
|
180
|
+
* file, so neither need be committed. The token needs Workers AI access and
|
|
181
|
+
* nothing else; `CLOUDFLARE_API_TOKEN` is deliberately not read, since on a host
|
|
182
|
+
* that also manages DNS it is the token with zone write.
|
|
183
|
+
*/
|
|
184
|
+
export interface ClefConfig {
|
|
185
|
+
accountId: string;
|
|
186
|
+
apiToken: string;
|
|
187
|
+
/** `clef` or `clef-flash`. Thresholds tuned on one do not carry to the other. */
|
|
188
|
+
model: 'clef' | 'clef-flash';
|
|
174
189
|
}
|
|
175
190
|
/** Input type for defineConfig — fields that have defaults are optional. */
|
|
176
|
-
export type HatkConfigInput = Partial<Omit<HatkConfig, 'oauth' | 'backfill' | 'push' | 'cdn' | 'jetstream' | 'spaces'>> & {
|
|
191
|
+
export type HatkConfigInput = Partial<Omit<HatkConfig, 'oauth' | 'backfill' | 'push' | 'cdn' | 'jetstream' | 'spaces' | 'clef'>> & {
|
|
177
192
|
cdn?: CdnConfig | null;
|
|
178
193
|
oauth?: (Partial<OAuthConfig> & {
|
|
179
194
|
clients: OAuthClientConfig[];
|
|
@@ -182,6 +197,7 @@ export type HatkConfigInput = Partial<Omit<HatkConfig, 'oauth' | 'backfill' | 'p
|
|
|
182
197
|
push?: PushConfig | null;
|
|
183
198
|
jetstream?: JetstreamConfig | null;
|
|
184
199
|
spaces?: SpacesConfig | null;
|
|
200
|
+
clef?: Partial<ClefConfig> | null;
|
|
185
201
|
};
|
|
186
202
|
/** Identity function that provides type inference for hatk config files. */
|
|
187
203
|
export declare function defineConfig(config: HatkConfigInput): HatkConfigInput;
|