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 +14 -0
- package/altalandi.mjs +32 -0
- package/batch.mjs +143 -20
- package/client.d.ts +238 -179
- package/client.mjs +288 -258
- package/package.json +1 -1
- package/report.mjs +20 -1
- package/skill/SKILL.md +17 -8
- package/update.mjs +1 -0
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
|
-
|
|
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
|
|
301
|
+
const requests = [];
|
|
241
302
|
let open = null;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
626
|
-
|
|
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
|
}
|