altalandi 0.6.2 → 0.6.4

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 CHANGED
@@ -99,6 +99,8 @@ altalandi --fast "..." # skip the meaning + nativeness gates
99
99
  altalandi --diff --file draft.txt # show before, after and every change with its reason
100
100
  altalandi --review --file in.txt --out out.txt # for agents: one block to show as it is
101
101
  altalandi --report --file draft.txt # also write a Markdown report to ./altalandi-reports/
102
+ altalandi --review --job j_20261013a1b2c3 --job-title "Handbók" --part "Hluti 1 af 3" --file part1.txt
103
+ # one part of a longer text: same --job on every part
102
104
  ```
103
105
 
104
106
  | flag | default | meaning |
@@ -112,6 +114,9 @@ altalandi --report --file draft.txt # also write a Markdown report to ./altal
112
114
  | `--out <file>` | | write the refined text to `<file>` (UTF-8); with `--review` the block names it when the text is shortened |
113
115
  | `--report` | off | also write `./altalandi-reports/altalandi-<time>-<id>.md`: input, output, a table of every change (before, after, reason, layer), warnings and the verdict |
114
116
  | `--report-dir <dir>` | | write the report to `<dir>` instead (implies `--report`) |
117
+ | `--job <id>` | | make this call a part of a job (verk): every call with the same id is one job on the site, one page for the whole text. The id is yours to choose: `j_` and 8–64 of `A-Z a-z 0-9 _ -`, e.g. `j_` + the date + a few random characters. With `--review`, the block links the job (`Verkið`) and this part (`Hlutinn`) |
118
+ | `--job-title <text>` | | the job's name (up to 120 characters); the first call that sends one names it |
119
+ | `--part <text>` | | this call's part, e.g. `Hluti 2 af 5` (up to 300 characters); needs `--job` |
115
120
  | `--fast` | off | `preserveMeaning:false`, `scoreNativeness:false`, `onlyImprove:false`, `polishPass:false`, `scoring:"none"` — normally one model call (structured input can still add a structure-preserving retry) |
116
121
 
117
122
  ## Folders, i18n catalogs and websites
@@ -182,11 +187,20 @@ agents) it sends nothing unless you pass `--yes`.
182
187
  | `--site` / `--max-pages <n>` | off / `20` | read a site from the URL, up to n pages |
183
188
  | `--no-store` | off | keep none of the passages (free use is otherwise kept, scrubbed of personal data) |
184
189
  | `--json` | off | a machine-readable summary of every file and passage on stdout |
190
+ | `--job <id>` / `--job-title <text>` | a new job | add the run to an existing job, or name it |
185
191
 
186
192
  A `429` waits as long as `Retry-After` asks (up to 5 minutes) and retries. A
187
193
  `402` stops the run cleanly: finished files are written, the rest are listed and
188
194
  not written; run the same command again after topping up.
189
195
 
196
+ **One job.** A run is one job (verk) on your account: every request sends the
197
+ same `job` id (`j_` + the date + 10 random characters, made per run), titled by
198
+ the folder's name or the site's host, and each file (its relative path) or page
199
+ (its URL) is a part. A batch that holds several small files is named by its
200
+ first file; a file that fits in one batch is not split across two. The summary
201
+ and the report show one `Verkið: <link>` to the job's page, not one link per
202
+ call. An engine that does not know jobs simply leaves the line out.
203
+
190
204
  **Results.** Only verified changes are written. A passage keeps its original
191
205
  text when its meaning check did not pass (`meaningCheck` other than `passed`, or
192
206
  `meaningPreserved` not `true`), or when the refined text lost or changed code, a
package/altalandi.mjs CHANGED
@@ -108,6 +108,9 @@ for (let i = 0; i < args.length; i++) {
108
108
  else if (a === "--yes" || a === "-y") opts.yes = true;
109
109
  else if (a === "--max-chars") opts.maxChars = Number(args[++i]);
110
110
  else if (a === "--concurrency") opts.concurrency = Number(args[++i]);
111
+ else if (a === "--job") opts.job = args[++i] ?? "";
112
+ else if (a === "--job-title") opts.jobTitle = args[++i] ?? "";
113
+ else if (a === "--part") opts.part = args[++i] ?? "";
111
114
  else positional.push(a);
112
115
  }
113
116
 
@@ -122,6 +125,9 @@ Usage:
122
125
  for agents: one block to show as it is
123
126
  echo "text" | altalandi
124
127
  altalandi --domain marketing --register warm "text"
128
+ altalandi --review --job j_20261013a1b2c3 --job-title "Handbók" --part "Hluti 1 af 3" --file part1.txt
129
+ one part of a longer text: every part with the same
130
+ --job is one verk (job) on the site
125
131
 
126
132
  Folders and websites:
127
133
  altalandi ./docs every .md .txt .html .json under ./docs → ./docs-is
@@ -154,6 +160,10 @@ Options:
154
160
  --report also write a Markdown report (input, output, changes, reasons, verdict)
155
161
  to ./altalandi-reports/ (or --report-dir <dir>; ALTALANDI_REPORT=1 turns it on)
156
162
  --file read the text from a UTF-8 file instead of an argument
163
+ --job a job id (j_ and 8-64 of A-Z a-z 0-9 _ -) you choose: calls with the same id
164
+ are the parts of one verk; --review then links the job and this part
165
+ --job-title the job's name, kept from the first call that sends one (up to 120 characters)
166
+ --part this call's part, e.g. "Hluti 2 af 5" (up to 300 characters; needs --job)
157
167
  --base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
158
168
 
159
169
  stdout = refined text; stderr = nativeness / meaning / warnings / diff.
@@ -169,6 +179,9 @@ Folder and website options:
169
179
  --site with a URL: its pages on the same site, under the URL's folder
170
180
  --max-pages pages to read with --site (default 20)
171
181
  --json print a machine-readable summary at the end
182
+ --job add the run to this job; --job-title names it (default: a new
183
+ job per run, named by the folder or the site's host; each file
184
+ or page is a part)
172
185
  Code, URLs, link targets, placeholders ({name}, %s, ICU), HTML tags and JSON keys
173
186
  stay byte for byte. Only changes whose meaning check passed are written; other
174
187
  passages keep the original text and are listed in the report
@@ -186,6 +199,13 @@ function readStdin() {
186
199
  });
187
200
  }
188
201
 
202
+ // --job / --job-title / --part: checked here so a bad id never reaches the API.
203
+ const JOB_ID = /^j_[A-Za-z0-9_-]{8,64}$/;
204
+ if (opts.job !== undefined && !JOB_ID.test(opts.job)) {
205
+ console.error(`--job must be j_ followed by 8 to 64 letters, digits, _ or - (for example j_20261013a1b2c3); got "${opts.job}".`);
206
+ process.exit(2);
207
+ }
208
+
189
209
  const stored = readCredentials();
190
210
  const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || stored?.baseUrl || "https://altalandi.is/api").replace(/\/$/, "");
191
211
  const { key } = resolveKey({ flag: opts.key });
@@ -206,6 +226,10 @@ if (opts.site || opts.dryRun || opts.maxChars !== undefined || opts.maxPages !==
206
226
  console.error("--site, --dry-run, --max-chars, --max-pages and --concurrency need a folder or a URL: altalandi ./docs --dry-run");
207
227
  process.exit(2);
208
228
  }
229
+ if (opts.job === undefined && (opts.jobTitle !== undefined || opts.part !== undefined)) {
230
+ console.error("--job-title and --part need --job <id>: the same id on every part of the text.");
231
+ process.exit(2);
232
+ }
209
233
 
210
234
  let text = positional.join(" ").trim();
211
235
  if (opts.file) {
@@ -224,6 +248,14 @@ const requestOptions = {
224
248
  register: opts.register,
225
249
  ...(opts.noStore ? { contribute: false } : {}),
226
250
  };
251
+ if (opts.job) {
252
+ const cap = (s, n) => [...String(s)].slice(0, n).join("");
253
+ requestOptions.job = {
254
+ id: opts.job,
255
+ ...(opts.jobTitle ? { title: cap(opts.jobTitle, 120) } : {}),
256
+ ...(opts.part ? { part: cap(opts.part, 300) } : {}),
257
+ };
258
+ }
227
259
  // --fast disables every optional quality/verification pass. The writer can
228
260
  // still retry once when it drops document structure, as documented in --help.
229
261
  // Name every switch explicitly so a future server-default change cannot make
package/batch.mjs CHANGED
@@ -8,6 +8,9 @@
8
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
+ // classify one free POST /v1/jobs with a sample cut across every passage,
12
+ // so the engine knows the kind of text and its fields; a failure
13
+ // (or an older engine's 404) is ignored and the run goes on
11
14
  // 4. refine short passages go together in batch requests (/v1/refine/batch),
12
15
  // longer ones one call each; 2 requests at a time by default; a
13
16
  // 429 waits for Retry-After, a 402 stops the run cleanly
@@ -19,12 +22,13 @@
19
22
  // Every side effect is injectable (io), so the tests need no network, terminal
20
23
  // or API key.
21
24
 
25
+ import { randomInt } from "node:crypto";
22
26
  import { existsSync, statSync } from "node:fs";
23
27
  import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
24
28
  import path from "node:path";
25
29
 
26
- import { refine, refineBatch } from "./client.mjs";
27
- import { CHARS_PER_WORD, charsEquivalent, formatChars, formatIsk, writeBatchReport } from "./report.mjs";
30
+ import { createJob, refine, refineBatch } from "./client.mjs";
31
+ import { CHARS_PER_WORD, charsEquivalent, formatChars, formatIsk, jobTypeText, writeBatchReport } from "./report.mjs";
28
32
  import { assemble, FORMATS, isConfigJson, repairCodeSpans, segment, segmentMarkdown, splitForApi } from "./segment.mjs";
29
33
  import { pagePath, readPage, readSite, safeSegment, userAgent } from "./web.mjs";
30
34
 
@@ -52,6 +56,16 @@ export const BATCH_MAX_CHARS = 6000;
52
56
  // nor the word units of an older engine.
53
57
  const MIN_BILLABLE_CHARS = 250;
54
58
  const BATCH_ITEM_MIN_CHARS = 130;
59
+ // One run is one job ("verk") on the account: every request names it, and each
60
+ // file or page is a part. The engine keeps a title of 120 and a part of 300.
61
+ export const JOB_ID_PATTERN = /^j_[A-Za-z0-9_-]{8,64}$/;
62
+ const JOB_TITLE_MAX = 120;
63
+ const JOB_PART_MAX = 300;
64
+ // The sample a run sends to POST /v1/jobs (the engine takes up to 12,000).
65
+ export const JOB_SAMPLE_CHARS = 3000;
66
+ const JOB_SAMPLE_MIN_PIECE = 300; // a long passage is cut to no less than this
67
+ const JOB_SAMPLE_MAX_PIECES = 80;
68
+ const JOB_CLASSIFY_TIMEOUT_MS = 20_000;
55
69
  const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
56
70
  const REGISTERS = ["formal", "neutral", "warm"];
57
71
 
@@ -73,6 +87,45 @@ const preview = (s, n = 72) => {
73
87
  return one.length > n ? `${one.slice(0, n - 1)}…` : one;
74
88
  };
75
89
 
90
+ /** A new job id: `j_`, the UTC date (yyyymmdd) and 10 random [a-z0-9]. */
91
+ export function newJobId(now = Date.now()) {
92
+ const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
93
+ const date = new Date(now).toISOString().slice(0, 10).replace(/-/g, "");
94
+ return `j_${date}${Array.from({ length: 10 }, () => abc[randomInt(abc.length)]).join("")}`;
95
+ }
96
+
97
+ /** At most n code points, so a cut never splits a surrogate pair. */
98
+ const cap = (s, n) => [...String(s)].slice(0, n).join("");
99
+
100
+ /** At most n code points of s, cut back to a word boundary when one is near. */
101
+ function cutAtWord(s, n) {
102
+ const cps = [...s];
103
+ if (cps.length <= n) return s;
104
+ const head = cps.slice(0, n).join("");
105
+ const space = head.search(/\s\S*$/);
106
+ return (space >= n / 2 ? head.slice(0, space) : head).trim();
107
+ }
108
+
109
+ /**
110
+ * The sample for POST /v1/jobs: passages spread evenly across the whole job
111
+ * (the first and the last always among them), about `budget` characters in
112
+ * all and never more. Each distinct passage counts once. When everything fits
113
+ * it is sent whole; otherwise as many evenly spaced passages as fit, each cut
114
+ * to an equal share (at least JOB_SAMPLE_MIN_PIECE characters, at a word).
115
+ */
116
+ export function jobSample(texts, budget = JOB_SAMPLE_CHARS) {
117
+ const all = [...new Set(texts.map((t) => String(t).replace(/\s+/g, " ").trim()).filter(Boolean))];
118
+ const len = (t) => [...t].length;
119
+ if (all.reduce((n, t) => n + len(t), 0) <= budget) return all;
120
+ const spread = (m) => (m === 1 ? [0] : Array.from({ length: m }, (_, j) => Math.round((j * (all.length - 1)) / (m - 1))));
121
+ for (let m = Math.min(all.length, JOB_SAMPLE_MAX_PIECES); m >= 1; m--) {
122
+ const share = Math.max(JOB_SAMPLE_MIN_PIECE, Math.floor(budget / m));
123
+ const picked = [...new Set(spread(m))].map((i) => cutAtWord(all[i], Math.min(share, budget)));
124
+ if (picked.reduce((n, t) => n + len(t), 0) <= budget) return picked.filter(Boolean);
125
+ }
126
+ return [cutAtWord(all[0], budget)];
127
+ }
128
+
76
129
  /** Billable characters: every Unicode code point of the trimmed text, spaces,
77
130
  * punctuation and line breaks included (the server's rule). */
78
131
  export const billableChars = (text) => [...String(text).trim()].length;
@@ -159,9 +212,10 @@ async function planFolder(opts, io) {
159
212
  }
160
213
  }
161
214
  file.status = file.note ? "copy" : "refine";
215
+ file.part = rel;
162
216
  files.push(file);
163
217
  }
164
- return { mode: "folder", label: opts.target, input, output, outputIsFolder: true, files };
218
+ return { mode: "folder", label: opts.target, title: path.basename(given), input, output, outputIsFolder: true, files };
165
219
  }
166
220
 
167
221
  const withSource = (page) => `<!-- Source: ${page.url} -->\n\n${page.markdown}\n`;
@@ -170,7 +224,7 @@ function pageFile(page, rel, target) {
170
224
  const text = withSource(page);
171
225
  const { units } = segmentMarkdown(text);
172
226
  return {
173
- path: rel, source: page.url, target, format: "md", text, units,
227
+ path: rel, source: page.url, part: page.url, target, format: "md", text, units,
174
228
  status: units.length ? "refine" : "copy", note: units.length ? undefined : "nothing to refine",
175
229
  };
176
230
  }
@@ -184,7 +238,7 @@ async function planWeb(opts, io, log) {
184
238
  const name = `${last === "index" ? safeSegment(new URL(page.url).host) : last}-is.md`;
185
239
  let target = path.resolve(cwd, opts.out || name);
186
240
  if (existsSync(target) && statSync(target).isDirectory()) target = path.join(target, name);
187
- return { mode: "page", label: opts.target, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
241
+ return { mode: "page", label: opts.target, title: new URL(page.url).host, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
188
242
  }
189
243
  log(`Reading ${opts.target} (up to ${plural(opts.maxPages, "page", "pages")}) …`);
190
244
  const { pages, skipped, source } = await readSite(opts.target, {
@@ -199,7 +253,7 @@ async function planWeb(opts, io, log) {
199
253
  taken.add(rel.toLowerCase());
200
254
  return pageFile(page, rel, path.join(output, ...rel.split("/")));
201
255
  });
202
- return { mode: "site", label: opts.target, output, outputIsFolder: true, files, skipped, discovery: source };
256
+ return { mode: "site", label: opts.target, title: new URL(pages[0].url).host, output, outputIsFolder: true, files, skipped, discovery: source };
203
257
  }
204
258
 
205
259
  // ── calls ───────────────────────────────────────────────────────────────────
@@ -225,7 +279,8 @@ function buildCalls(files, maxChars) {
225
279
  continue;
226
280
  }
227
281
  budgetUsed += added;
228
- for (const [text, call] of fresh) calls.set(text, call);
282
+ // A call belongs to the first file that sends it: that file is its part.
283
+ for (const [text, call] of fresh) calls.set(text, Object.assign(call, { part: file.part }));
229
284
  for (const unit of file.units) {
230
285
  unit.file = file;
231
286
  for (const call of new Set(unit.calls)) call.units.push(unit);
@@ -235,18 +290,36 @@ function buildCalls(files, maxChars) {
235
290
  }
236
291
 
237
292
  /** The requests of a run: short calls together in batches of at most
238
- * BATCH_MAX_ITEMS items and BATCH_MAX_CHARS characters, longer ones alone. */
293
+ * BATCH_MAX_ITEMS items and BATCH_MAX_CHARS characters, longer ones alone.
294
+ * A batch takes whole files: when a file's short calls fit in a batch of
295
+ * their own but not in the room left in the open one, that batch is closed
296
+ * and the file starts the next, so a file is split only when it alone is
297
+ * bigger than a batch. Small files still share a batch (named by the first
298
+ * file's part). The limits are unchanged, and closing a batch early costs
299
+ * nothing: two or more items are already past the call minimum. */
239
300
  export function groupCalls(calls) {
240
- const jobs = [];
301
+ const requests = [];
241
302
  let open = null;
242
- for (const call of calls) {
243
- if (call.text.length > BATCH_PASSAGE_MAX_CHARS) { jobs.push({ batch: false, calls: [call] }); continue; }
244
- if (open && (open.calls.length >= BATCH_MAX_ITEMS || open.chars + call.text.length > BATCH_MAX_CHARS)) open = null;
245
- if (!open) { open = { batch: true, calls: [], chars: 0 }; jobs.push(open); }
246
- open.calls.push(call);
247
- open.chars += call.text.length;
303
+ const isShort = (c) => c.text.length <= BATCH_PASSAGE_MAX_CHARS;
304
+ for (let i = 0; i < calls.length;) {
305
+ let end = i;
306
+ while (end < calls.length && calls[end].part === calls[i].part) end++;
307
+ const file = calls.slice(i, end);
308
+ i = end;
309
+ const short = file.filter(isShort);
310
+ const chars = short.reduce((n, c) => n + c.text.length, 0);
311
+ const fitsAlone = short.length <= BATCH_MAX_ITEMS && chars <= BATCH_MAX_CHARS;
312
+ if (open && short.length && fitsAlone
313
+ && (open.calls.length + short.length > BATCH_MAX_ITEMS || open.chars + chars > BATCH_MAX_CHARS)) open = null;
314
+ for (const call of file) {
315
+ if (!isShort(call)) { requests.push({ batch: false, calls: [call] }); continue; }
316
+ if (open && (open.calls.length >= BATCH_MAX_ITEMS || open.chars + call.text.length > BATCH_MAX_CHARS)) open = null;
317
+ if (!open) { open = { batch: true, calls: [], chars: 0 }; requests.push(open); }
318
+ open.calls.push(call);
319
+ open.chars += call.text.length;
320
+ }
248
321
  }
249
- return jobs;
322
+ return requests;
250
323
  }
251
324
 
252
325
  /** Characters a request is billed: the call minimum for one text; for a batch,
@@ -413,6 +486,11 @@ export async function runBatch(opts, io = {}) {
413
486
  const maxPages = opts.maxPages ?? 20;
414
487
  if (!Number.isInteger(maxPages) || maxPages < 1) { log("--max-pages must be a positive whole number."); return EXIT_ERROR; }
415
488
  if (opts.site && !isUrl) { log("--site needs a URL, such as https://example.com"); return EXIT_ERROR; }
489
+ 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; }
490
+ if (opts.job !== undefined && !JOB_ID_PATTERN.test(String(opts.job))) {
491
+ log("--job must be j_ followed by 8 to 64 letters, digits, _ or - (for example j_20261013a1b2c3).");
492
+ return EXIT_ERROR;
493
+ }
416
494
  // Checked here, not by the API: a bad value would otherwise fail every call.
417
495
  if (!DOMAINS.includes(opts.domain ?? "general")) { log(`--domain must be one of: ${DOMAINS.join(", ")}`); return EXIT_ERROR; }
418
496
  if (!REGISTERS.includes(opts.register ?? "neutral")) { log(`--register must be one of: ${REGISTERS.join(", ")}`); return EXIT_ERROR; }
@@ -519,6 +597,22 @@ export async function runBatch(opts, io = {}) {
519
597
  ...(opts.noStore ? { contribute: false } : {}),
520
598
  ...(io.fetch ? { fetch: io.fetch } : {}),
521
599
  };
600
+ // One job for the whole run (--job joins an existing one); each request names
601
+ // its part: the file's relative path, or the page URL.
602
+ const runJob = {
603
+ id: opts.job || newJobId(now()),
604
+ title: cap(opts.jobTitle || plan.title || plan.label || "", JOB_TITLE_MAX),
605
+ };
606
+ const withPart = (part) => ({
607
+ ...requestOptions,
608
+ job: { id: runJob.id, ...(runJob.title ? { title: runJob.title } : {}), ...(part ? { part: cap(part, JOB_PART_MAX) } : {}) },
609
+ });
610
+ // The job's page, from the first response that names it (an older engine never does).
611
+ const noteJob = (data) => {
612
+ if (state.jobUrl) return;
613
+ const url = [data?.jobUrl, data?.items?.[0]?.jobUrl].find((u) => typeof u === "string" && u);
614
+ if (url) Object.assign(state, { jobUrl: url, jobId: typeof data.jobId === "string" && data.jobId ? data.jobId : runJob.id });
615
+ };
522
616
 
523
617
  const pause = async () => {
524
618
  for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
@@ -615,18 +709,21 @@ export async function runBatch(opts, io = {}) {
615
709
  };
616
710
 
617
711
  const single = async (call) => {
618
- const r = await attempt(() => refine(call.text, requestOptions));
619
- if (r.data) bill(r.data);
712
+ const r = await attempt(() => refine(call.text, withPart(call.part)));
713
+ if (r.data) { bill(r.data); noteJob(r.data); }
620
714
  return r;
621
715
  };
622
716
 
623
717
  /** One batch request; every item is mapped back to its call by id. */
624
718
  const sendBatch = async (job) => {
625
- const items = job.calls.map((c, k) => ({ id: String(k), text: c.text }));
626
- const r = await attempt(() => refineBatch(items, requestOptions), { batch: true });
719
+ // Each item names its own file (or page): a batch that holds several small
720
+ // files reports each one on the job page. The request is named by its first.
721
+ const items = job.calls.map((c, k) => ({ id: String(k), text: c.text, ...(c.part ? { part: cap(c.part, JOB_PART_MAX) } : {}) }));
722
+ const r = await attempt(() => refineBatch(items, withPart(job.calls[0].part)), { batch: true });
627
723
  if (!r.data) return r;
628
724
  const data = r.data;
629
725
  bill(data);
726
+ noteJob(data);
630
727
  const byId = new Map(data.items.map((it) => [String(it.id), it]));
631
728
  const artifacts = data.quality?.encodingArtifacts || [];
632
729
  job.calls.forEach((call, k) => {
@@ -642,6 +739,28 @@ export async function runBatch(opts, io = {}) {
642
739
  return { done: true };
643
740
  };
644
741
 
742
+ // Before the first refine call: one free POST /v1/jobs with a sample of the
743
+ // whole run, so the engine knows the kind of text and its fields for every
744
+ // call. Never stops the run: an older engine answers 404, and any error is
745
+ // ignored (the engine then classifies from the first call's own text).
746
+ if (calls.length && !state.stop) {
747
+ // Every passage of every file that is sent (not the ones left out by --max-chars).
748
+ const texts = refining().flatMap((f) => f.units.map((u) => u.text));
749
+ try {
750
+ const data = await createJob({
751
+ id: runJob.id, ...(runJob.title ? { title: runJob.title } : {}), sample: jobSample(texts),
752
+ }, { apiKey: opts.key, baseUrl, timeoutMs: JOB_CLASSIFY_TIMEOUT_MS, ...(io.fetch ? { fetch: io.fetch } : {}) });
753
+ const c = data.classification;
754
+ if (c && typeof c === "object") {
755
+ state.classification = c;
756
+ log(`Tegund: ${jobTypeText(c) || "óviss"}`);
757
+ }
758
+ if (typeof data.jobUrl === "string" && data.jobUrl) Object.assign(state, { jobUrl: data.jobUrl, jobId: data.id || runJob.id });
759
+ } catch {
760
+ // 404 (no jobs on this engine), a network error or a timeout: carry on.
761
+ }
762
+ }
763
+
645
764
  const alone = (job) => job.calls.map((call) => ({ batch: false, calls: [call] }));
646
765
  const hadWork = refining().length > 0;
647
766
  const queue = [...jobs];
@@ -724,6 +843,8 @@ export async function runBatch(opts, io = {}) {
724
843
  units: state.billed,
725
844
  },
726
845
  ...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
846
+ ...(state.jobUrl ? { jobId: state.jobId, jobUrl: state.jobUrl } : {}),
847
+ ...(state.classification ? { classification: state.classification } : {}),
727
848
  files,
728
849
  };
729
850
 
@@ -758,6 +879,8 @@ export async function runBatch(opts, io = {}) {
758
879
  log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
759
880
  + `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
760
881
  + (hadWork ? ` Passages: ${fmt(t.changed)} changed, ${fmt(t.unchangedPassages)} needed no change, ${fmt(t.kept)} kept as they were, ${fmt(t.failed)} failed.` : ""));
882
+ // One link for the whole run, never one per call.
883
+ if (summary.jobUrl) log(`Verkið: ${summary.jobUrl}`);
761
884
  if (t.billedCharacters != null) {
762
885
  log(`Rukkað: ${t.billedCharactersApprox ? "um " : ""}${formatChars(t.billedCharacters)} (${charsEquivalent(t.billedCharacters)}).`);
763
886
  }