aeacus-cli 0.0.0-stage → 0.1.0
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/README.md +16 -2
- package/package.json +17 -4
- package/src/cli.js +204 -0
- package/src/tokenizer.js +112 -0
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Aeacus CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`aeacus` reads a text file or standard input, scores it with the published Aeacus model, and prints a verdict. The first run downloads the model, tokenizer, and calibration settings (about 82 MB) from Hugging Face and caches them under `~/.cache/aeacus/`.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
cat draft.txt | aeacus
|
|
7
|
+
aeacus draft.txt
|
|
8
|
+
aeacus draft.txt --json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Default output is a short summary with excerpts of likely AI regions. Regions combine adjacent tokens with the same classification. `--json` emits one JSON object, including grouped regions with character offsets, scores, and excerpts. `--porcelain` emits stable tab-separated summary fields.
|
|
12
|
+
|
|
13
|
+
The document `ai_score` is the mean calibrated score across tokens. It is an AI-likeness heuristic, not a probability of authorship. A score at or above 0.5 is `ai_leaning`; below 0.5 is `human_leaning`. Text under 30 words is `insufficient_text`, since the model was trained on longer passages. Per-token regions use the model's calibrated thresholds and may be `likely_ai`, `likely_human`, or `uncertain`.
|
|
14
|
+
|
|
15
|
+
The CLI runs on CPU. Use `--model-dir` to point to a local model directory containing `tokenizer.json`, `thresholds.json`, and `onnx/model_q4f16.onnx`. `AEACUS_CACHE_DIR` overrides the default model cache location.
|
|
16
|
+
|
|
17
|
+
This package will be published to npm as `aeacus-cli`, exposing the command `aeacus`. The CLI package has no license declaration. Publish its first version once from an npm-authenticated environment, then configure npm Trusted Publishing for this repository and `.github/workflows/publish-cli.yml`. Later releases build a package archive and publish when a matching `cli-v<version>` tag is pushed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aeacus-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Check writing with the Aeacus human-vs-AI classifier",
|
|
5
|
+
"repository": "git+https://github.com/SeriousBug/aeacus.git",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"aeacus": "./src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"onnxruntime-node": "1.30.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import ort from "onnxruntime-node";
|
|
7
|
+
import { Tokenizer } from "./tokenizer.js";
|
|
8
|
+
|
|
9
|
+
const REVISION = "57d4ce806931beff3d0150f74f330b831a9efb80";
|
|
10
|
+
const MODEL_FILES = ["tokenizer.json", "thresholds.json", "onnx/model_q4f16.onnx"];
|
|
11
|
+
const WINDOW = 510;
|
|
12
|
+
const STRIDE = 128;
|
|
13
|
+
|
|
14
|
+
function usage() {
|
|
15
|
+
return `Usage: aeacus [file] [--json|--porcelain] [--model-dir directory]
|
|
16
|
+
|
|
17
|
+
Read text from a file or stdin and estimate how AI-like its writing is.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--json Output a JSON object for agents and scripts
|
|
21
|
+
--porcelain Output stable tab-separated summary fields
|
|
22
|
+
--model-dir PATH Use a local folder with tokenizer.json, thresholds.json,
|
|
23
|
+
and onnx/model_q4f16.onnx
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
cat draft.txt | aeacus
|
|
28
|
+
aeacus draft.txt --json
|
|
29
|
+
cat draft.txt | aeacus --porcelain`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseArgs(args) {
|
|
33
|
+
const opts = { format: "text", modelDir: null, input: null };
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
const arg = args[i];
|
|
36
|
+
if (arg === "--json") opts.format = "json";
|
|
37
|
+
else if (arg === "--porcelain") opts.format = "porcelain";
|
|
38
|
+
else if (arg === "--model-dir") {
|
|
39
|
+
if (!args[i + 1]) throw new Error("--model-dir requires a path");
|
|
40
|
+
opts.modelDir = path.resolve(args[++i]);
|
|
41
|
+
} else if (arg === "-h" || arg === "--help") opts.help = true;
|
|
42
|
+
else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
|
|
43
|
+
else if (opts.input) throw new Error("Provide one input file, or pipe text through stdin");
|
|
44
|
+
else opts.input = arg;
|
|
45
|
+
}
|
|
46
|
+
return opts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cacheDir() {
|
|
50
|
+
return process.env.AEACUS_CACHE_DIR
|
|
51
|
+
? path.resolve(process.env.AEACUS_CACHE_DIR)
|
|
52
|
+
: path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "aeacus", REVISION);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function ensureFile(root, relative) {
|
|
56
|
+
const file = path.join(root, relative);
|
|
57
|
+
try {
|
|
58
|
+
await fs.access(file);
|
|
59
|
+
return file;
|
|
60
|
+
} catch {
|
|
61
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
62
|
+
const url = `https://huggingface.co/level20tauren/aeacus-s-1/resolve/${REVISION}/${relative}`;
|
|
63
|
+
process.stderr.write(`Downloading ${relative} from the Aeacus model repository…\n`);
|
|
64
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
65
|
+
if (!response.ok) throw new Error(`Model download failed (${response.status}): ${relative}`);
|
|
66
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
67
|
+
await fs.writeFile(temp, Buffer.from(await response.arrayBuffer()));
|
|
68
|
+
await fs.rename(temp, file);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function loadModel(modelDir) {
|
|
74
|
+
const root = modelDir ?? cacheDir();
|
|
75
|
+
const files = await Promise.all(MODEL_FILES.map((file) => ensureFile(root, file)));
|
|
76
|
+
const [tokenizerJson, thresholds] = await Promise.all([
|
|
77
|
+
fs.readFile(files[0], "utf8").then(JSON.parse),
|
|
78
|
+
fs.readFile(files[1], "utf8").then(JSON.parse),
|
|
79
|
+
]);
|
|
80
|
+
const session = await ort.InferenceSession.create(files[2], { executionProviders: ["cpu"] });
|
|
81
|
+
return { tokenizer: new Tokenizer(tokenizerJson), thresholds, session };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readInput(file) {
|
|
85
|
+
if (file) return fs.readFile(file, "utf8");
|
|
86
|
+
if (process.stdin.isTTY) throw new Error("Provide a file or pipe text to aeacus on stdin");
|
|
87
|
+
const chunks = [];
|
|
88
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
89
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function calibrate(value, knots) {
|
|
93
|
+
const { x, y } = knots;
|
|
94
|
+
if (value <= x[0]) return y[0];
|
|
95
|
+
for (let i = 1; i < x.length; i++) {
|
|
96
|
+
if (value <= x[i]) {
|
|
97
|
+
const t = (value - x[i - 1]) / (x[i] - x[i - 1] || 1);
|
|
98
|
+
return y[i - 1] + t * (y[i] - y[i - 1]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return y.at(-1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function classification(score, thresholds) {
|
|
105
|
+
if (score >= thresholds.high) return "likely_ai";
|
|
106
|
+
if (score <= thresholds.low) return "likely_human";
|
|
107
|
+
return "uncertain";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function regions(text, encoded, probabilities, thresholds) {
|
|
111
|
+
const result = [];
|
|
112
|
+
for (let i = 0; i < probabilities.length; i++) {
|
|
113
|
+
const verdict = classification(probabilities[i], thresholds);
|
|
114
|
+
const previous = result.at(-1);
|
|
115
|
+
if (previous && previous.verdict === verdict) {
|
|
116
|
+
previous.end = encoded.ends[i];
|
|
117
|
+
previous.ai_score = (previous.ai_score * previous.tokens + probabilities[i]) / (previous.tokens + 1);
|
|
118
|
+
previous.tokens++;
|
|
119
|
+
} else {
|
|
120
|
+
result.push({ start: encoded.starts[i], end: encoded.ends[i], verdict,
|
|
121
|
+
ai_score: probabilities[i], tokens: 1 });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result.map((region) => ({
|
|
125
|
+
...region,
|
|
126
|
+
ai_score: Number(region.ai_score.toFixed(4)),
|
|
127
|
+
text: text.slice(region.start, region.end),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function score(text, model) {
|
|
132
|
+
const encoded = model.tokenizer.encode(text);
|
|
133
|
+
const sums = new Float64Array(encoded.ids.length);
|
|
134
|
+
const counts = new Uint16Array(encoded.ids.length);
|
|
135
|
+
const windowCount = Math.max(1, Math.ceil(Math.max(0, encoded.ids.length - WINDOW) / (WINDOW - STRIDE)) + 1);
|
|
136
|
+
for (let w = 0; w < windowCount; w++) {
|
|
137
|
+
const from = w * (WINDOW - STRIDE);
|
|
138
|
+
const to = Math.min(from + WINDOW, encoded.ids.length);
|
|
139
|
+
const input = BigInt64Array.from([model.tokenizer.clsId, ...encoded.ids.slice(from, to), model.tokenizer.sepId], BigInt);
|
|
140
|
+
const mask = new BigInt64Array(input.length).fill(1n);
|
|
141
|
+
const out = await model.session.run({
|
|
142
|
+
input_ids: new ort.Tensor("int64", input, [1, input.length]),
|
|
143
|
+
attention_mask: new ort.Tensor("int64", mask, [1, input.length]),
|
|
144
|
+
});
|
|
145
|
+
const logits = out.logits.data;
|
|
146
|
+
for (let i = from; i < to; i++) {
|
|
147
|
+
const k = (i - from + 1) * 2;
|
|
148
|
+
const raw = 1 / (1 + Math.exp(logits[k] - logits[k + 1]));
|
|
149
|
+
sums[i] += raw;
|
|
150
|
+
counts[i]++;
|
|
151
|
+
}
|
|
152
|
+
out.logits.dispose?.();
|
|
153
|
+
}
|
|
154
|
+
const probabilities = Array.from(sums, (sum, i) => calibrate(sum / counts[i], model.thresholds.calibration));
|
|
155
|
+
const aiScore = probabilities.length
|
|
156
|
+
? probabilities.reduce((sum, probability) => sum + probability, 0) / probabilities.length
|
|
157
|
+
: null;
|
|
158
|
+
const wordCount = (text.match(/\S+/g) ?? []).length;
|
|
159
|
+
const verdict = wordCount < 30 ? "insufficient_text"
|
|
160
|
+
: aiScore >= 0.5 ? "ai_leaning" : "human_leaning";
|
|
161
|
+
return {
|
|
162
|
+
verdict,
|
|
163
|
+
ai_score: aiScore === null ? null : Number(aiScore.toFixed(4)),
|
|
164
|
+
words: wordCount,
|
|
165
|
+
tokens: probabilities.length,
|
|
166
|
+
regions: regions(text, encoded, probabilities, model.thresholds),
|
|
167
|
+
thresholds: { likely_human: model.thresholds.low, likely_ai: model.thresholds.high },
|
|
168
|
+
note: "ai_score is the mean calibrated token score, not a probability of authorship. The verdict is a heuristic; short texts are marked insufficient.",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function printResult(result, format) {
|
|
173
|
+
if (format === "json") {
|
|
174
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
175
|
+
} else if (format === "porcelain") {
|
|
176
|
+
process.stdout.write(`verdict\t${result.verdict}\nai_score\t${result.ai_score ?? "null"}\nwords\t${result.words}\ntokens\t${result.tokens}\n`);
|
|
177
|
+
} else {
|
|
178
|
+
const score = result.ai_score === null ? "unavailable" : `${Math.round(result.ai_score * 100)}%`;
|
|
179
|
+
process.stdout.write(`Aeacus: ${result.verdict.replaceAll("_", " ")} (AI-like score ${score}; ${result.words} words)\n`);
|
|
180
|
+
const likelyAi = result.regions.filter((r) => r.verdict === "likely_ai");
|
|
181
|
+
if (likelyAi.length) {
|
|
182
|
+
process.stdout.write("Likely AI regions:\n");
|
|
183
|
+
for (const region of likelyAi) {
|
|
184
|
+
const excerpt = region.text.replace(/\s+/g, " ").trim();
|
|
185
|
+
process.stdout.write(` ${Math.round(region.ai_score * 100)}% ${excerpt}\n`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (result.verdict === "insufficient_text") process.stdout.write("The model was trained on passages of at least 30 words.\n");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
194
|
+
if (opts.help) {
|
|
195
|
+
process.stdout.write(`${usage()}\n`);
|
|
196
|
+
} else {
|
|
197
|
+
const text = (await readInput(opts.input)).normalize("NFC");
|
|
198
|
+
const model = await loadModel(opts.modelDir);
|
|
199
|
+
printResult(await score(text, model), opts.format);
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
process.stderr.write(`aeacus: ${error.message}\n`);
|
|
203
|
+
process.exitCode = 2;
|
|
204
|
+
}
|
package/src/tokenizer.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Byte-level BPE tokenizer matching the model's tokenizer.json.
|
|
2
|
+
const PRETOKENIZE = /'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
|
|
3
|
+
|
|
4
|
+
function byteToUnicode() {
|
|
5
|
+
const bytes = [];
|
|
6
|
+
for (let b = 33; b <= 126; b++) bytes.push(b);
|
|
7
|
+
for (let b = 161; b <= 172; b++) bytes.push(b);
|
|
8
|
+
for (let b = 174; b <= 255; b++) bytes.push(b);
|
|
9
|
+
const chars = bytes.slice();
|
|
10
|
+
let n = 0;
|
|
11
|
+
for (let b = 0; b < 256; b++) {
|
|
12
|
+
if (!bytes.includes(b)) {
|
|
13
|
+
bytes.push(b);
|
|
14
|
+
chars.push(256 + n++);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const map = new Array(256);
|
|
18
|
+
bytes.forEach((b, i) => { map[b] = String.fromCharCode(chars[i]); });
|
|
19
|
+
return map;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Tokenizer {
|
|
23
|
+
constructor(json) {
|
|
24
|
+
this.vocab = new Map(Object.entries(json.model.vocab));
|
|
25
|
+
this.ranks = new Map(json.model.merges.map((m, i) => [Array.isArray(m) ? m.join(" ") : m, i]));
|
|
26
|
+
this.added = new Map(json.added_tokens.map((t) => [t.content, t.id]));
|
|
27
|
+
const escaped = [...this.added.keys()].sort((a, b) => b.length - a.length)
|
|
28
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
29
|
+
this.addedPattern = new RegExp(escaped.join("|"), "g");
|
|
30
|
+
this.byteChars = byteToUnicode();
|
|
31
|
+
this.cache = new Map();
|
|
32
|
+
this.clsId = this.added.get("[CLS]");
|
|
33
|
+
this.sepId = this.added.get("[SEP]");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
encode(text) {
|
|
37
|
+
const ids = [], starts = [], ends = [];
|
|
38
|
+
let pos = 0;
|
|
39
|
+
const plain = (end) => {
|
|
40
|
+
if (end > pos) this.#encodePlain(text, pos, end, ids, starts, ends);
|
|
41
|
+
};
|
|
42
|
+
for (const m of text.matchAll(this.addedPattern)) {
|
|
43
|
+
plain(m.index);
|
|
44
|
+
ids.push(this.added.get(m[0]));
|
|
45
|
+
starts.push(m.index);
|
|
46
|
+
ends.push(m.index + m[0].length);
|
|
47
|
+
pos = m.index + m[0].length;
|
|
48
|
+
}
|
|
49
|
+
plain(text.length);
|
|
50
|
+
return { ids, starts, ends };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#encodePlain(text, from, to, ids, starts, ends) {
|
|
54
|
+
const segment = text.slice(from, to);
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
for (const m of segment.matchAll(PRETOKENIZE)) {
|
|
57
|
+
const piece = m[0];
|
|
58
|
+
const byteChar = [];
|
|
59
|
+
let chars = "";
|
|
60
|
+
for (let i = 0; i < piece.length;) {
|
|
61
|
+
const cp = piece.codePointAt(i);
|
|
62
|
+
const width = cp > 0xffff ? 2 : 1;
|
|
63
|
+
for (const b of encoder.encode(String.fromCodePoint(cp))) {
|
|
64
|
+
byteChar.push([i, width]);
|
|
65
|
+
chars += this.byteChars[b];
|
|
66
|
+
}
|
|
67
|
+
i += width;
|
|
68
|
+
}
|
|
69
|
+
const base = from + m.index;
|
|
70
|
+
let byte = 0;
|
|
71
|
+
for (const token of this.#bpe(chars)) {
|
|
72
|
+
const first = byteChar[byte];
|
|
73
|
+
const last = byteChar[byte + token.length - 1];
|
|
74
|
+
ids.push(this.vocab.get(token));
|
|
75
|
+
starts.push(base + first[0]);
|
|
76
|
+
ends.push(base + last[0] + last[1]);
|
|
77
|
+
byte += token.length;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#bpe(word) {
|
|
83
|
+
const cached = this.cache.get(word);
|
|
84
|
+
if (cached) return cached;
|
|
85
|
+
let parts = [...word];
|
|
86
|
+
while (parts.length > 1) {
|
|
87
|
+
let best = -1, bestRank = Infinity;
|
|
88
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
89
|
+
const rank = this.ranks.get(parts[i] + " " + parts[i + 1]);
|
|
90
|
+
if (rank !== undefined && rank < bestRank) {
|
|
91
|
+
bestRank = rank;
|
|
92
|
+
best = i;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (best < 0) break;
|
|
96
|
+
const merged = parts[best] + parts[best + 1];
|
|
97
|
+
const next = [];
|
|
98
|
+
for (let i = 0; i < parts.length; i++) {
|
|
99
|
+
if (i < parts.length - 1 && parts[i] + parts[i + 1] === merged
|
|
100
|
+
&& this.ranks.get(parts[i] + " " + parts[i + 1]) === bestRank) {
|
|
101
|
+
next.push(merged);
|
|
102
|
+
i++;
|
|
103
|
+
} else {
|
|
104
|
+
next.push(parts[i]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
parts = next;
|
|
108
|
+
}
|
|
109
|
+
if (this.cache.size < 50000) this.cache.set(word, parts);
|
|
110
|
+
return parts;
|
|
111
|
+
}
|
|
112
|
+
}
|