altalandi 0.6.1 → 0.6.3
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 +35 -12
- package/altalandi.mjs +32 -0
- package/auth.mjs +2 -4
- package/batch.mjs +257 -46
- package/client.d.ts +200 -122
- package/client.mjs +260 -219
- package/package.json +1 -1
- package/report.mjs +75 -13
- package/skill/SKILL.md +35 -19
- package/update.mjs +3 -0
package/batch.mjs
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// The order of things is the safety story:
|
|
5
5
|
// 1. plan read every document and cut it into passages (segment.mjs,
|
|
6
6
|
// web.mjs); nothing is sent yet
|
|
7
|
-
// 2. estimate files, passages
|
|
8
|
-
// anything is sent
|
|
7
|
+
// 2. estimate files, passages and the characters billed, with the price,
|
|
8
|
+
// printed before anything is sent
|
|
9
9
|
// 3. consent a y/N question in a terminal, or --yes; with neither, nothing
|
|
10
10
|
// is sent (--dry-run stops here on purpose)
|
|
11
|
-
// 4. refine
|
|
12
|
-
//
|
|
11
|
+
// 4. refine short passages go together in batch requests (/v1/refine/batch),
|
|
12
|
+
// longer ones one call each; 2 requests at a time by default; a
|
|
13
|
+
// 429 waits for Retry-After, a 402 stops the run cleanly
|
|
13
14
|
// 5. write each file once all its passages are back; a passage whose
|
|
14
15
|
// meaning check did not pass keeps its original text; nothing is
|
|
15
16
|
// ever written inside the input
|
|
@@ -18,12 +19,13 @@
|
|
|
18
19
|
// Every side effect is injectable (io), so the tests need no network, terminal
|
|
19
20
|
// or API key.
|
|
20
21
|
|
|
22
|
+
import { randomInt } from "node:crypto";
|
|
21
23
|
import { existsSync, statSync } from "node:fs";
|
|
22
24
|
import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
23
25
|
import path from "node:path";
|
|
24
26
|
|
|
25
|
-
import { refine } from "./client.mjs";
|
|
26
|
-
import { writeBatchReport } from "./report.mjs";
|
|
27
|
+
import { refine, refineBatch } from "./client.mjs";
|
|
28
|
+
import { CHARS_PER_WORD, charsEquivalent, formatChars, formatIsk, writeBatchReport } from "./report.mjs";
|
|
27
29
|
import { assemble, FORMATS, isConfigJson, repairCodeSpans, segment, segmentMarkdown, splitForApi } from "./segment.mjs";
|
|
28
30
|
import { pagePath, readPage, readSite, safeSegment, userAgent } from "./web.mjs";
|
|
29
31
|
|
|
@@ -39,6 +41,23 @@ const MAX_RETRY_WAIT_MS = 5 * 60_000; // a longer Retry-After means "come back l
|
|
|
39
41
|
const MAX_RATE_LIMIT_RETRIES = 8;
|
|
40
42
|
const MAX_TRANSIENT_RETRIES = 2;
|
|
41
43
|
const ERRORS_IN_A_ROW_TO_STOP = 3;
|
|
44
|
+
// Short passages go to /v1/refine/batch: each is billed at least 130 characters
|
|
45
|
+
// there instead of the 250-character call minimum, which is paid once per
|
|
46
|
+
// request. A request
|
|
47
|
+
// stays small enough to come back well inside the web proxy's ~300 s timeout
|
|
48
|
+
// (the server refines 4 at a time: ~40 short items take a minute or two).
|
|
49
|
+
export const BATCH_PASSAGE_MAX_CHARS = 1500;
|
|
50
|
+
export const BATCH_MAX_ITEMS = 40;
|
|
51
|
+
export const BATCH_MAX_CHARS = 6000;
|
|
52
|
+
// When /v1/pricing says neither (minimumBillableChars / batchItemMinimumChars)
|
|
53
|
+
// nor the word units of an older engine.
|
|
54
|
+
const MIN_BILLABLE_CHARS = 250;
|
|
55
|
+
const BATCH_ITEM_MIN_CHARS = 130;
|
|
56
|
+
// One run is one job ("verk") on the account: every request names it, and each
|
|
57
|
+
// file or page is a part. The engine keeps a title of 120 and a part of 300.
|
|
58
|
+
export const JOB_ID_PATTERN = /^j_[A-Za-z0-9_-]{8,64}$/;
|
|
59
|
+
const JOB_TITLE_MAX = 120;
|
|
60
|
+
const JOB_PART_MAX = 300;
|
|
42
61
|
const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
|
|
43
62
|
const REGISTERS = ["formal", "neutral", "warm"];
|
|
44
63
|
|
|
@@ -60,10 +79,19 @@ const preview = (s, n = 72) => {
|
|
|
60
79
|
return one.length > n ? `${one.slice(0, n - 1)}…` : one;
|
|
61
80
|
};
|
|
62
81
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
82
|
+
/** A new job id: `j_`, the UTC date (yyyymmdd) and 10 random [a-z0-9]. */
|
|
83
|
+
export function newJobId(now = Date.now()) {
|
|
84
|
+
const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
85
|
+
const date = new Date(now).toISOString().slice(0, 10).replace(/-/g, "");
|
|
86
|
+
return `j_${date}${Array.from({ length: 10 }, () => abc[randomInt(abc.length)]).join("")}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** At most n code points, so a cut never splits a surrogate pair. */
|
|
90
|
+
const cap = (s, n) => [...String(s)].slice(0, n).join("");
|
|
91
|
+
|
|
92
|
+
/** Billable characters: every Unicode code point of the trimmed text, spaces,
|
|
93
|
+
* punctuation and line breaks included (the server's rule). */
|
|
94
|
+
export const billableChars = (text) => [...String(text).trim()].length;
|
|
67
95
|
|
|
68
96
|
/** The real path of p, or of its deepest existing ancestor plus the rest, so a
|
|
69
97
|
* symlinked --out is compared where it really points. */
|
|
@@ -147,9 +175,10 @@ async function planFolder(opts, io) {
|
|
|
147
175
|
}
|
|
148
176
|
}
|
|
149
177
|
file.status = file.note ? "copy" : "refine";
|
|
178
|
+
file.part = rel;
|
|
150
179
|
files.push(file);
|
|
151
180
|
}
|
|
152
|
-
return { mode: "folder", label: opts.target, input, output, outputIsFolder: true, files };
|
|
181
|
+
return { mode: "folder", label: opts.target, title: path.basename(given), input, output, outputIsFolder: true, files };
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
const withSource = (page) => `<!-- Source: ${page.url} -->\n\n${page.markdown}\n`;
|
|
@@ -158,7 +187,7 @@ function pageFile(page, rel, target) {
|
|
|
158
187
|
const text = withSource(page);
|
|
159
188
|
const { units } = segmentMarkdown(text);
|
|
160
189
|
return {
|
|
161
|
-
path: rel, source: page.url, target, format: "md", text, units,
|
|
190
|
+
path: rel, source: page.url, part: page.url, target, format: "md", text, units,
|
|
162
191
|
status: units.length ? "refine" : "copy", note: units.length ? undefined : "nothing to refine",
|
|
163
192
|
};
|
|
164
193
|
}
|
|
@@ -172,7 +201,7 @@ async function planWeb(opts, io, log) {
|
|
|
172
201
|
const name = `${last === "index" ? safeSegment(new URL(page.url).host) : last}-is.md`;
|
|
173
202
|
let target = path.resolve(cwd, opts.out || name);
|
|
174
203
|
if (existsSync(target) && statSync(target).isDirectory()) target = path.join(target, name);
|
|
175
|
-
return { mode: "page", label: opts.target, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
|
|
204
|
+
return { mode: "page", label: opts.target, title: new URL(page.url).host, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
|
|
176
205
|
}
|
|
177
206
|
log(`Reading ${opts.target} (up to ${plural(opts.maxPages, "page", "pages")}) …`);
|
|
178
207
|
const { pages, skipped, source } = await readSite(opts.target, {
|
|
@@ -187,7 +216,7 @@ async function planWeb(opts, io, log) {
|
|
|
187
216
|
taken.add(rel.toLowerCase());
|
|
188
217
|
return pageFile(page, rel, path.join(output, ...rel.split("/")));
|
|
189
218
|
});
|
|
190
|
-
return { mode: "site", label: opts.target, output, outputIsFolder: true, files, skipped, discovery: source };
|
|
219
|
+
return { mode: "site", label: opts.target, title: new URL(pages[0].url).host, output, outputIsFolder: true, files, skipped, discovery: source };
|
|
191
220
|
}
|
|
192
221
|
|
|
193
222
|
// ── calls ───────────────────────────────────────────────────────────────────
|
|
@@ -213,7 +242,8 @@ function buildCalls(files, maxChars) {
|
|
|
213
242
|
continue;
|
|
214
243
|
}
|
|
215
244
|
budgetUsed += added;
|
|
216
|
-
|
|
245
|
+
// A call belongs to the first file that sends it: that file is its part.
|
|
246
|
+
for (const [text, call] of fresh) calls.set(text, Object.assign(call, { part: file.part }));
|
|
217
247
|
for (const unit of file.units) {
|
|
218
248
|
unit.file = file;
|
|
219
249
|
for (const call of new Set(unit.calls)) call.units.push(unit);
|
|
@@ -222,6 +252,49 @@ function buildCalls(files, maxChars) {
|
|
|
222
252
|
return [...calls.values()];
|
|
223
253
|
}
|
|
224
254
|
|
|
255
|
+
/** The requests of a run: short calls together in batches of at most
|
|
256
|
+
* BATCH_MAX_ITEMS items and BATCH_MAX_CHARS characters, longer ones alone.
|
|
257
|
+
* A batch takes whole files: when a file's short calls fit in a batch of
|
|
258
|
+
* their own but not in the room left in the open one, that batch is closed
|
|
259
|
+
* and the file starts the next, so a file is split only when it alone is
|
|
260
|
+
* bigger than a batch. Small files still share a batch (named by the first
|
|
261
|
+
* file's part). The limits are unchanged, and closing a batch early costs
|
|
262
|
+
* nothing: two or more items are already past the call minimum. */
|
|
263
|
+
export function groupCalls(calls) {
|
|
264
|
+
const requests = [];
|
|
265
|
+
let open = null;
|
|
266
|
+
const isShort = (c) => c.text.length <= BATCH_PASSAGE_MAX_CHARS;
|
|
267
|
+
for (let i = 0; i < calls.length;) {
|
|
268
|
+
let end = i;
|
|
269
|
+
while (end < calls.length && calls[end].part === calls[i].part) end++;
|
|
270
|
+
const file = calls.slice(i, end);
|
|
271
|
+
i = end;
|
|
272
|
+
const short = file.filter(isShort);
|
|
273
|
+
const chars = short.reduce((n, c) => n + c.text.length, 0);
|
|
274
|
+
const fitsAlone = short.length <= BATCH_MAX_ITEMS && chars <= BATCH_MAX_CHARS;
|
|
275
|
+
if (open && short.length && fitsAlone
|
|
276
|
+
&& (open.calls.length + short.length > BATCH_MAX_ITEMS || open.chars + chars > BATCH_MAX_CHARS)) open = null;
|
|
277
|
+
for (const call of file) {
|
|
278
|
+
if (!isShort(call)) { requests.push({ batch: false, calls: [call] }); continue; }
|
|
279
|
+
if (open && (open.calls.length >= BATCH_MAX_ITEMS || open.chars + call.text.length > BATCH_MAX_CHARS)) open = null;
|
|
280
|
+
if (!open) { open = { batch: true, calls: [], chars: 0 }; requests.push(open); }
|
|
281
|
+
open.calls.push(call);
|
|
282
|
+
open.chars += call.text.length;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return requests;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Characters a request is billed: the call minimum for one text; for a batch,
|
|
289
|
+
* each item at least the item minimum, the call minimum once for the request. */
|
|
290
|
+
function jobChars(job, pricing) {
|
|
291
|
+
if (!job.batch) return Math.max(pricing.minChars, billableChars(job.calls[0].text));
|
|
292
|
+
const items = job.calls.reduce((n, c) => n + Math.max(pricing.itemMinChars, billableChars(c.text)), 0);
|
|
293
|
+
return Math.max(pricing.minChars, items);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const fin = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
297
|
+
|
|
225
298
|
async function fetchPricing(baseUrl, fetchImpl) {
|
|
226
299
|
const controller = new AbortController();
|
|
227
300
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
@@ -230,12 +303,20 @@ async function fetchPricing(baseUrl, fetchImpl) {
|
|
|
230
303
|
if (!res.ok) return null;
|
|
231
304
|
const p = JSON.parse(await res.text());
|
|
232
305
|
const lane = p?.tiers?.standard?.fast;
|
|
233
|
-
if (!
|
|
306
|
+
if (!lane || typeof lane !== "object") return null;
|
|
307
|
+
// Characters (billingUnit "character"); an engine from before the switch
|
|
308
|
+
// gives word units, converted with charsPerWord.
|
|
309
|
+
const cpw = fin(p.charsPerWord) > 0 ? p.charsPerWord : CHARS_PER_WORD;
|
|
310
|
+
const chars = (c, u) => fin(p[c]) ?? (fin(p[u]) != null ? Math.round(p[u] * cpw) : null);
|
|
311
|
+
// The tier's price already carries its weight, as iskPer1000Units did.
|
|
312
|
+
const per100 = fin(lane.iskPer100Chars) ?? (fin(lane.iskPer1000Units) != null ? lane.iskPer1000Units / 10 / cpw : null);
|
|
234
313
|
return {
|
|
235
|
-
|
|
314
|
+
minChars: chars("minimumBillableChars", "minimumBillableUnits") ?? MIN_BILLABLE_CHARS,
|
|
315
|
+
itemMinChars: chars("batchItemMinimumChars", "batchItemMinimumUnits") ?? BATCH_ITEM_MIN_CHARS,
|
|
236
316
|
multiplier: Number(lane.multiplier) || 1,
|
|
237
|
-
|
|
238
|
-
freeDaily:
|
|
317
|
+
iskPerChar: per100 != null ? per100 / 100 : null,
|
|
318
|
+
freeDaily: chars("accountFreeDailyChars", "accountFreeDailyUnits") ?? 0,
|
|
319
|
+
charsPerWord: cpw,
|
|
239
320
|
};
|
|
240
321
|
} catch {
|
|
241
322
|
return null;
|
|
@@ -244,14 +325,22 @@ async function fetchPricing(baseUrl, fetchImpl) {
|
|
|
244
325
|
}
|
|
245
326
|
}
|
|
246
327
|
|
|
247
|
-
function estimateLines(plan, calls, pricing, opts) {
|
|
328
|
+
function estimateLines(plan, calls, jobs, pricing, opts) {
|
|
248
329
|
const refining = plan.files.filter((f) => f.status === "refine");
|
|
249
330
|
const passages = refining.reduce((n, f) => n + f.units.length, 0);
|
|
250
331
|
const chars = calls.reduce((n, c) => n + c.text.length, 0);
|
|
251
332
|
const lines = [
|
|
252
333
|
`${plural(refining.length, "file", "files")}, ${plural(passages, "passage", "passages")}, about ${fmt(chars)} characters`
|
|
253
|
-
+ `${calls.length !== passages ? ` (${
|
|
334
|
+
+ `${calls.length !== passages ? ` (${fmt(calls.length)} to send: repeated passages are sent once)` : ""}.`,
|
|
254
335
|
];
|
|
336
|
+
const batches = jobs.filter((j) => j.batch);
|
|
337
|
+
if (calls.length) {
|
|
338
|
+
const together = batches.reduce((n, j) => n + j.calls.length, 0);
|
|
339
|
+
const alone = jobs.length - batches.length;
|
|
340
|
+
lines.push(`${plural(jobs.length, "request", "requests")}: `
|
|
341
|
+
+ [together && `${plural(together, "short passage", "short passages")} together in ${plural(batches.length, "batch", "batches")}`,
|
|
342
|
+
alone && `${plural(alone, "longer passage", "longer passages")} one by one`].filter(Boolean).join(", ") + ".");
|
|
343
|
+
}
|
|
255
344
|
const copied = plan.files.filter((f) => f.status === "copy").length;
|
|
256
345
|
if (copied) lines.push(`${plural(copied, "more file has", "more files have")} nothing to refine and ${copied === 1 ? "is" : "are"} copied unchanged.`);
|
|
257
346
|
const over = plan.files.filter((f) => f.status === "over-budget");
|
|
@@ -260,10 +349,16 @@ function estimateLines(plan, calls, pricing, opts) {
|
|
|
260
349
|
+ `(from ${over[0].path} on).`);
|
|
261
350
|
}
|
|
262
351
|
if (pricing && calls.length) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
352
|
+
// In Icelandic, as on the site: characters, one equivalent and krónur;
|
|
353
|
+
// never the rate per 100 characters.
|
|
354
|
+
const raw = jobs.reduce((n, j) => n + jobChars(j, pricing), 0);
|
|
355
|
+
const billed = raw * pricing.multiplier;
|
|
356
|
+
const isk = pricing.iskPerChar != null ? raw * pricing.iskPerChar : null;
|
|
357
|
+
const price = isk != null ? `, um ${formatIsk(isk >= 10 ? Math.round(isk) : isk)} ef greitt er af inneign` : "";
|
|
358
|
+
lines.push(`Kostnaður: um ${formatChars(billed)} (${charsEquivalent(billed)})${price}.`);
|
|
359
|
+
const rule = `Hvert kall telst minnst ${formatChars(pricing.minChars)}`
|
|
360
|
+
+ (batches.length ? `; margir stuttir textar í einu kalli minnst ${formatChars(pricing.itemMinChars)} hver.` : ".");
|
|
361
|
+
lines.push(rule + (pricing.freeDaily ? ` ${formatChars(pricing.freeDaily)} á dag eru ókeypis.` : ""));
|
|
267
362
|
}
|
|
268
363
|
return lines;
|
|
269
364
|
}
|
|
@@ -354,6 +449,11 @@ export async function runBatch(opts, io = {}) {
|
|
|
354
449
|
const maxPages = opts.maxPages ?? 20;
|
|
355
450
|
if (!Number.isInteger(maxPages) || maxPages < 1) { log("--max-pages must be a positive whole number."); return EXIT_ERROR; }
|
|
356
451
|
if (opts.site && !isUrl) { log("--site needs a URL, such as https://example.com"); return EXIT_ERROR; }
|
|
452
|
+
if (opts.part !== undefined) { log("--part is for one text. A folder or site run names each part by its file or page."); return EXIT_ERROR; }
|
|
453
|
+
if (opts.job !== undefined && !JOB_ID_PATTERN.test(String(opts.job))) {
|
|
454
|
+
log("--job must be j_ followed by 8 to 64 letters, digits, _ or - (for example j_20261013a1b2c3).");
|
|
455
|
+
return EXIT_ERROR;
|
|
456
|
+
}
|
|
357
457
|
// Checked here, not by the API: a bad value would otherwise fail every call.
|
|
358
458
|
if (!DOMAINS.includes(opts.domain ?? "general")) { log(`--domain must be one of: ${DOMAINS.join(", ")}`); return EXIT_ERROR; }
|
|
359
459
|
if (!REGISTERS.includes(opts.register ?? "neutral")) { log(`--register must be one of: ${REGISTERS.join(", ")}`); return EXIT_ERROR; }
|
|
@@ -369,6 +469,7 @@ export async function runBatch(opts, io = {}) {
|
|
|
369
469
|
return EXIT_ERROR;
|
|
370
470
|
}
|
|
371
471
|
const calls = buildCalls(plan.files, opts.maxChars);
|
|
472
|
+
const jobs = groupCalls(calls);
|
|
372
473
|
const baseUrl = (opts.baseUrl || "https://altalandi.is/api").replace(/\/+$/, "");
|
|
373
474
|
const where = `${plan.label} → ${display(plan.output, cwd)}`;
|
|
374
475
|
const refining = () => plan.files.filter((f) => f.status === "refine");
|
|
@@ -379,7 +480,7 @@ export async function runBatch(opts, io = {}) {
|
|
|
379
480
|
}
|
|
380
481
|
// The public tariff: no key, no text sent, nothing charged.
|
|
381
482
|
const pricing = calls.length ? await fetchPricing(baseUrl, fetchImpl) : null;
|
|
382
|
-
const estimate = estimateLines(plan, calls, pricing, opts);
|
|
483
|
+
const estimate = estimateLines(plan, calls, jobs, pricing, opts);
|
|
383
484
|
|
|
384
485
|
if (opts.dryRun) {
|
|
385
486
|
if (opts.json) {
|
|
@@ -434,7 +535,18 @@ export async function runBatch(opts, io = {}) {
|
|
|
434
535
|
await writeFile(target, data);
|
|
435
536
|
};
|
|
436
537
|
|
|
437
|
-
const state = { stop: null, pausedUntil: 0, errorsInARow: 0 };
|
|
538
|
+
const state = { stop: null, pausedUntil: 0, errorsInARow: 0, noBatch: false, billed: null, billedChars: null, approx: false };
|
|
539
|
+
// What a response says it billed: characters, or the word units of an
|
|
540
|
+
// engine from before the switch (converted, and marked as approximate).
|
|
541
|
+
const bill = (data) => {
|
|
542
|
+
const b = data?.billing || {};
|
|
543
|
+
const units = fin(b.units) ?? fin(data?.units);
|
|
544
|
+
if (units != null) state.billed = (state.billed ?? 0) + units;
|
|
545
|
+
const chars = fin(b.characters) ?? fin(data?.characters);
|
|
546
|
+
if (chars == null && units == null) return;
|
|
547
|
+
if (chars == null) state.approx = true;
|
|
548
|
+
state.billedChars = (state.billedChars ?? 0) + (chars ?? units * (pricing?.charsPerWord ?? CHARS_PER_WORD));
|
|
549
|
+
};
|
|
438
550
|
const stop = (reason, message) => { if (!state.stop) state.stop = { reason, message }; };
|
|
439
551
|
|
|
440
552
|
for (const f of plan.files.filter((x) => x.status === "copy")) {
|
|
@@ -448,20 +560,40 @@ export async function runBatch(opts, io = {}) {
|
|
|
448
560
|
...(opts.noStore ? { contribute: false } : {}),
|
|
449
561
|
...(io.fetch ? { fetch: io.fetch } : {}),
|
|
450
562
|
};
|
|
563
|
+
// One job for the whole run (--job joins an existing one); each request names
|
|
564
|
+
// its part: the file's relative path, or the page URL.
|
|
565
|
+
const runJob = {
|
|
566
|
+
id: opts.job || newJobId(now()),
|
|
567
|
+
title: cap(opts.jobTitle || plan.title || plan.label || "", JOB_TITLE_MAX),
|
|
568
|
+
};
|
|
569
|
+
const withPart = (part) => ({
|
|
570
|
+
...requestOptions,
|
|
571
|
+
job: { id: runJob.id, ...(runJob.title ? { title: runJob.title } : {}), ...(part ? { part: cap(part, JOB_PART_MAX) } : {}) },
|
|
572
|
+
});
|
|
573
|
+
// The job's page, from the first response that names it (an older engine never does).
|
|
574
|
+
const noteJob = (data) => {
|
|
575
|
+
if (state.jobUrl) return;
|
|
576
|
+
const url = [data?.jobUrl, data?.items?.[0]?.jobUrl].find((u) => typeof u === "string" && u);
|
|
577
|
+
if (url) Object.assign(state, { jobUrl: url, jobId: typeof data.jobId === "string" && data.jobId ? data.jobId : runJob.id });
|
|
578
|
+
};
|
|
451
579
|
|
|
452
580
|
const pause = async () => {
|
|
453
581
|
for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
|
|
454
582
|
};
|
|
455
583
|
|
|
456
|
-
|
|
584
|
+
// One request with the run's retry rules: `send` is one /v1/refine or one
|
|
585
|
+
// /v1/refine/batch call. A batch endpoint the server does not have (404/405,
|
|
586
|
+
// an older self-hosted engine) answers { unsupported }.
|
|
587
|
+
const attempt = async (send, { batch = false } = {}) => {
|
|
457
588
|
let rateLimited = 0;
|
|
458
589
|
let transient = 0;
|
|
459
590
|
for (;;) {
|
|
460
591
|
await pause();
|
|
461
592
|
if (state.stop) return { skipped: true };
|
|
462
593
|
try {
|
|
463
|
-
return { data: await
|
|
594
|
+
return { data: await send() };
|
|
464
595
|
} catch (e) {
|
|
596
|
+
if (batch && (e.status === 404 || e.status === 405)) return { unsupported: true };
|
|
465
597
|
if (e.status === 429) {
|
|
466
598
|
rateLimited++;
|
|
467
599
|
const ms = Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : Math.min(60_000, 2000 * 2 ** (rateLimited - 1));
|
|
@@ -515,32 +647,99 @@ export async function runBatch(opts, io = {}) {
|
|
|
515
647
|
}
|
|
516
648
|
};
|
|
517
649
|
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
650
|
+
const countError = (error) => {
|
|
651
|
+
state.errorsInARow++;
|
|
652
|
+
if (state.errorsInARow >= ERRORS_IN_A_ROW_TO_STOP) stop("error", `${state.errorsInARow} calls in a row failed; the last: ${error}`);
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const done = async (job) => {
|
|
656
|
+
if (job.calls.some((c) => c.result?.skipped)) return;
|
|
657
|
+
if (job.failed) countError(job.failed); // a whole request failed: one error, not one per passage
|
|
658
|
+
else {
|
|
659
|
+
for (const call of job.calls) {
|
|
660
|
+
if (call.result.error) countError(call.result.error);
|
|
661
|
+
else state.errorsInARow = 0;
|
|
662
|
+
}
|
|
525
663
|
}
|
|
526
|
-
for (const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
664
|
+
for (const call of job.calls) {
|
|
665
|
+
for (const unit of call.units) {
|
|
666
|
+
if (unit.status || !unit.calls.every((c) => c.result && !c.result.skipped)) continue;
|
|
667
|
+
settle(unit);
|
|
668
|
+
const file = unit.file;
|
|
669
|
+
if (file.status === "refine" && file.units.every((u) => u.status)) await finish(file);
|
|
670
|
+
}
|
|
531
671
|
}
|
|
532
672
|
};
|
|
533
673
|
|
|
674
|
+
const single = async (call) => {
|
|
675
|
+
const r = await attempt(() => refine(call.text, withPart(call.part)));
|
|
676
|
+
if (r.data) { bill(r.data); noteJob(r.data); }
|
|
677
|
+
return r;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
/** One batch request; every item is mapped back to its call by id. */
|
|
681
|
+
const sendBatch = async (job) => {
|
|
682
|
+
const items = job.calls.map((c, k) => ({ id: String(k), text: c.text }));
|
|
683
|
+
// A batch that holds several small files is named by its first.
|
|
684
|
+
const r = await attempt(() => refineBatch(items, withPart(job.calls[0].part)), { batch: true });
|
|
685
|
+
if (!r.data) return r;
|
|
686
|
+
const data = r.data;
|
|
687
|
+
bill(data);
|
|
688
|
+
noteJob(data);
|
|
689
|
+
const byId = new Map(data.items.map((it) => [String(it.id), it]));
|
|
690
|
+
const artifacts = data.quality?.encodingArtifacts || [];
|
|
691
|
+
job.calls.forEach((call, k) => {
|
|
692
|
+
const item = byId.get(String(k));
|
|
693
|
+
if (!item) call.result = { error: "the batch response has no result for this passage" };
|
|
694
|
+
else if (item.error) call.result = { error: String(item.error) };
|
|
695
|
+
else {
|
|
696
|
+
// The batch's quality block covers every item; keep the mojibake guard per item.
|
|
697
|
+
const own = artifacts.filter((a) => a && String(item.output).includes(a));
|
|
698
|
+
call.result = { data: { ...item, id: data.id || undefined, quality: { encodingArtifacts: own } } };
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
return { done: true };
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const alone = (job) => job.calls.map((call) => ({ batch: false, calls: [call] }));
|
|
534
705
|
const hadWork = refining().length > 0;
|
|
535
|
-
const queue = [...
|
|
706
|
+
const queue = [...jobs];
|
|
536
707
|
const worker = async () => {
|
|
537
708
|
while (queue.length && !state.stop) {
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
709
|
+
const job = queue.shift();
|
|
710
|
+
if (job.batch && state.noBatch) { queue.unshift(...alone(job)); continue; }
|
|
711
|
+
if (!job.batch) {
|
|
712
|
+
job.calls[0].result = await single(job.calls[0]);
|
|
713
|
+
} else {
|
|
714
|
+
const r = await sendBatch(job);
|
|
715
|
+
if (r.unsupported) {
|
|
716
|
+
if (!state.noBatch) {
|
|
717
|
+
state.noBatch = true;
|
|
718
|
+
log("Note: this server has no batch endpoint (/v1/refine/batch), so every passage is sent on its own "
|
|
719
|
+
+ `(each call is billed at least ${fmt(pricing?.minChars ?? MIN_BILLABLE_CHARS)} characters).`);
|
|
720
|
+
}
|
|
721
|
+
queue.unshift(...alone(job));
|
|
722
|
+
fill(); // one batch became many calls: use every worker for them
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (!r.done) {
|
|
726
|
+
for (const call of job.calls) call.result = r;
|
|
727
|
+
if (r.error) job.failed = r.error;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
await done(job);
|
|
541
731
|
}
|
|
542
732
|
};
|
|
543
|
-
|
|
733
|
+
const running = [];
|
|
734
|
+
let active = 0;
|
|
735
|
+
function fill() {
|
|
736
|
+
while (active < concurrency && queue.length && !state.stop) {
|
|
737
|
+
active++;
|
|
738
|
+
running.push(worker().finally(() => { active--; }));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
fill();
|
|
742
|
+
while (running.length) await Promise.all(running.splice(0));
|
|
544
743
|
|
|
545
744
|
for (const f of plan.files) if (f.status === "refine") f.status = "unfinished";
|
|
546
745
|
|
|
@@ -576,8 +775,15 @@ export async function runBatch(opts, io = {}) {
|
|
|
576
775
|
kept: count("kept"),
|
|
577
776
|
failed: count("failed"),
|
|
578
777
|
characters: calls.filter((c) => c.result && !c.result.skipped).reduce((n, c) => n + c.text.length, 0),
|
|
778
|
+
// What the server says it billed, summed over the responses (null: none
|
|
779
|
+
// said). billedCharacters is what is shown; units is the word equivalent.
|
|
780
|
+
billedCharacters: state.billedChars == null ? null
|
|
781
|
+
: state.approx ? Math.round(state.billedChars / 10) * 10 : Math.round(state.billedChars),
|
|
782
|
+
...(state.approx ? { billedCharactersApprox: true } : {}),
|
|
783
|
+
units: state.billed,
|
|
579
784
|
},
|
|
580
785
|
...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
|
|
786
|
+
...(state.jobUrl ? { jobId: state.jobId, jobUrl: state.jobUrl } : {}),
|
|
581
787
|
files,
|
|
582
788
|
};
|
|
583
789
|
|
|
@@ -612,6 +818,11 @@ export async function runBatch(opts, io = {}) {
|
|
|
612
818
|
log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
|
|
613
819
|
+ `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
|
|
614
820
|
+ (hadWork ? ` Passages: ${fmt(t.changed)} changed, ${fmt(t.unchangedPassages)} needed no change, ${fmt(t.kept)} kept as they were, ${fmt(t.failed)} failed.` : ""));
|
|
821
|
+
// One link for the whole run, never one per call.
|
|
822
|
+
if (summary.jobUrl) log(`Verkið: ${summary.jobUrl}`);
|
|
823
|
+
if (t.billedCharacters != null) {
|
|
824
|
+
log(`Rukkað: ${t.billedCharactersApprox ? "um " : ""}${formatChars(t.billedCharacters)} (${charsEquivalent(t.billedCharacters)}).`);
|
|
825
|
+
}
|
|
615
826
|
if (t.kept) log(`${plural(t.kept, "passage keeps", "passages keep")} the original text because a check did not pass; the report lists ${t.kept === 1 ? "it" : "them"}.`);
|
|
616
827
|
if (t.overBudget) log(`${plural(t.overBudget, "file was", "files were")} left out for --max-chars.`);
|
|
617
828
|
if (summary.report) log(`Report: ${display(summary.report, cwd)}`);
|