altalandi 0.6.2 → 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 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
@@ -19,6 +19,7 @@
19
19
  // Every side effect is injectable (io), so the tests need no network, terminal
20
20
  // or API key.
21
21
 
22
+ import { randomInt } from "node:crypto";
22
23
  import { existsSync, statSync } from "node:fs";
23
24
  import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
24
25
  import path from "node:path";
@@ -52,6 +53,11 @@ export const BATCH_MAX_CHARS = 6000;
52
53
  // nor the word units of an older engine.
53
54
  const MIN_BILLABLE_CHARS = 250;
54
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;
55
61
  const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
56
62
  const REGISTERS = ["formal", "neutral", "warm"];
57
63
 
@@ -73,6 +79,16 @@ const preview = (s, n = 72) => {
73
79
  return one.length > n ? `${one.slice(0, n - 1)}…` : one;
74
80
  };
75
81
 
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
+
76
92
  /** Billable characters: every Unicode code point of the trimmed text, spaces,
77
93
  * punctuation and line breaks included (the server's rule). */
78
94
  export const billableChars = (text) => [...String(text).trim()].length;
@@ -159,9 +175,10 @@ async function planFolder(opts, io) {
159
175
  }
160
176
  }
161
177
  file.status = file.note ? "copy" : "refine";
178
+ file.part = rel;
162
179
  files.push(file);
163
180
  }
164
- 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 };
165
182
  }
166
183
 
167
184
  const withSource = (page) => `<!-- Source: ${page.url} -->\n\n${page.markdown}\n`;
@@ -170,7 +187,7 @@ function pageFile(page, rel, target) {
170
187
  const text = withSource(page);
171
188
  const { units } = segmentMarkdown(text);
172
189
  return {
173
- path: rel, source: page.url, target, format: "md", text, units,
190
+ path: rel, source: page.url, part: page.url, target, format: "md", text, units,
174
191
  status: units.length ? "refine" : "copy", note: units.length ? undefined : "nothing to refine",
175
192
  };
176
193
  }
@@ -184,7 +201,7 @@ async function planWeb(opts, io, log) {
184
201
  const name = `${last === "index" ? safeSegment(new URL(page.url).host) : last}-is.md`;
185
202
  let target = path.resolve(cwd, opts.out || name);
186
203
  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)] };
204
+ return { mode: "page", label: opts.target, title: new URL(page.url).host, output: target, outputIsFolder: false, files: [pageFile(page, path.basename(target), target)] };
188
205
  }
189
206
  log(`Reading ${opts.target} (up to ${plural(opts.maxPages, "page", "pages")}) …`);
190
207
  const { pages, skipped, source } = await readSite(opts.target, {
@@ -199,7 +216,7 @@ async function planWeb(opts, io, log) {
199
216
  taken.add(rel.toLowerCase());
200
217
  return pageFile(page, rel, path.join(output, ...rel.split("/")));
201
218
  });
202
- 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 };
203
220
  }
204
221
 
205
222
  // ── calls ───────────────────────────────────────────────────────────────────
@@ -225,7 +242,8 @@ function buildCalls(files, maxChars) {
225
242
  continue;
226
243
  }
227
244
  budgetUsed += added;
228
- 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 }));
229
247
  for (const unit of file.units) {
230
248
  unit.file = file;
231
249
  for (const call of new Set(unit.calls)) call.units.push(unit);
@@ -235,18 +253,36 @@ function buildCalls(files, maxChars) {
235
253
  }
236
254
 
237
255
  /** 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. */
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. */
239
263
  export function groupCalls(calls) {
240
- const jobs = [];
264
+ const requests = [];
241
265
  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;
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
+ }
248
284
  }
249
- return jobs;
285
+ return requests;
250
286
  }
251
287
 
252
288
  /** Characters a request is billed: the call minimum for one text; for a batch,
@@ -413,6 +449,11 @@ export async function runBatch(opts, io = {}) {
413
449
  const maxPages = opts.maxPages ?? 20;
414
450
  if (!Number.isInteger(maxPages) || maxPages < 1) { log("--max-pages must be a positive whole number."); return EXIT_ERROR; }
415
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
+ }
416
457
  // Checked here, not by the API: a bad value would otherwise fail every call.
417
458
  if (!DOMAINS.includes(opts.domain ?? "general")) { log(`--domain must be one of: ${DOMAINS.join(", ")}`); return EXIT_ERROR; }
418
459
  if (!REGISTERS.includes(opts.register ?? "neutral")) { log(`--register must be one of: ${REGISTERS.join(", ")}`); return EXIT_ERROR; }
@@ -519,6 +560,22 @@ export async function runBatch(opts, io = {}) {
519
560
  ...(opts.noStore ? { contribute: false } : {}),
520
561
  ...(io.fetch ? { fetch: io.fetch } : {}),
521
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
+ };
522
579
 
523
580
  const pause = async () => {
524
581
  for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
@@ -615,18 +672,20 @@ export async function runBatch(opts, io = {}) {
615
672
  };
616
673
 
617
674
  const single = async (call) => {
618
- const r = await attempt(() => refine(call.text, requestOptions));
619
- if (r.data) bill(r.data);
675
+ const r = await attempt(() => refine(call.text, withPart(call.part)));
676
+ if (r.data) { bill(r.data); noteJob(r.data); }
620
677
  return r;
621
678
  };
622
679
 
623
680
  /** One batch request; every item is mapped back to its call by id. */
624
681
  const sendBatch = async (job) => {
625
682
  const items = job.calls.map((c, k) => ({ id: String(k), text: c.text }));
626
- const r = await attempt(() => refineBatch(items, requestOptions), { batch: true });
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 });
627
685
  if (!r.data) return r;
628
686
  const data = r.data;
629
687
  bill(data);
688
+ noteJob(data);
630
689
  const byId = new Map(data.items.map((it) => [String(it.id), it]));
631
690
  const artifacts = data.quality?.encodingArtifacts || [];
632
691
  job.calls.forEach((call, k) => {
@@ -724,6 +783,7 @@ export async function runBatch(opts, io = {}) {
724
783
  units: state.billed,
725
784
  },
726
785
  ...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
786
+ ...(state.jobUrl ? { jobId: state.jobId, jobUrl: state.jobUrl } : {}),
727
787
  files,
728
788
  };
729
789
 
@@ -758,6 +818,8 @@ export async function runBatch(opts, io = {}) {
758
818
  log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
759
819
  + `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
760
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}`);
761
823
  if (t.billedCharacters != null) {
762
824
  log(`Rukkað: ${t.billedCharactersApprox ? "um " : ""}${formatChars(t.billedCharacters)} (${charsEquivalent(t.billedCharacters)}).`);
763
825
  }