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/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, characters and billing units, printed before
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 one call per passage, 2 at a time by default; a 429 waits for
12
- // Retry-After, a 402 stops the run cleanly
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
- /** Every whitespace token is at least one billing unit; a long unbroken token
64
- * is one unit per 20 characters (the server's rule, docs/api/README.md). */
65
- const billingTokens = (text) => text.split(/\s+/).filter(Boolean)
66
- .reduce((n, t) => n + Math.max(1, Math.ceil(t.length / 20)), 0);
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
- for (const [text, call] of fresh) calls.set(text, call);
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 (!Number.isFinite(p?.minimumBillableUnits) || !lane) return null;
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
- minUnits: p.minimumBillableUnits,
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
- iskPer1000: Number(lane.iskPer1000Units),
238
- freeDaily: Number(p.accountFreeDailyUnits) || 0,
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 ? ` (${plural(calls.length, "call", "calls")}: repeated passages are sent once)` : ""}.`,
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
- const units = calls.reduce((n, c) => n + Math.max(pricing.minUnits, billingTokens(c.text)), 0) * pricing.multiplier;
264
- const isk = Number.isFinite(pricing.iskPer1000) ? `, about ${fmt((units * pricing.iskPer1000) / 1000)} kr if paid from credit` : "";
265
- lines.push(`Billing: about ${fmt(units)} units (at least ${fmt(pricing.minUnits)} a call)${isk}.`
266
- + `${pricing.freeDaily ? ` The free allowance is ${fmt(pricing.freeDaily)} units a day.` : ""}`);
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
- const callOnce = async (call) => {
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 refine(call.text, requestOptions) };
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 done = async (call) => {
519
- if (call.result.skipped) return;
520
- if (call.result.error) {
521
- state.errorsInARow++;
522
- if (state.errorsInARow >= ERRORS_IN_A_ROW_TO_STOP) stop("error", `${state.errorsInARow} calls in a row failed; the last: ${call.result.error}`);
523
- } else {
524
- state.errorsInARow = 0;
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 unit of call.units) {
527
- if (unit.status || !unit.calls.every((c) => c.result && !c.result.skipped)) continue;
528
- settle(unit);
529
- const file = unit.file;
530
- if (file.status === "refine" && file.units.every((u) => u.status)) await finish(file);
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 = [...calls];
706
+ const queue = [...jobs];
536
707
  const worker = async () => {
537
708
  while (queue.length && !state.stop) {
538
- const call = queue.shift();
539
- call.result = await callOnce(call);
540
- await done(call);
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
- await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, calls.length)) }, worker));
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)}`);