altalandi 0.1.0 → 0.1.1
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/LICENSE +21 -21
- package/README.md +121 -53
- package/altalandi.mjs +122 -87
- package/client.d.ts +113 -0
- package/client.mjs +208 -0
- package/package.json +53 -17
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Altalandi
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Altalandi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,53 +1,121 @@
|
|
|
1
|
-
# altalandi
|
|
2
|
-
|
|
3
|
-
Turn English or stiff/AI-translated Icelandic into Icelandic that reads as if a
|
|
4
|
-
native wrote it
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
1
|
+
# altalandi
|
|
2
|
+
|
|
3
|
+
Turn English or stiff/AI-translated Icelandic into Icelandic that reads as if a
|
|
4
|
+
native wrote it—from your terminal or application. This package provides the
|
|
5
|
+
`altalandi` CLI and a typed, zero-dependency JavaScript client for the hosted
|
|
6
|
+
[Altalandi API](https://altalandi.is) (Node ≥ 18).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# Mint once at https://altalandi.is/portal, then enter it without shell history:
|
|
10
|
+
read -rsp "Altalandi API key: " ALTALANDI_API_KEY; echo
|
|
11
|
+
export ALTALANDI_API_KEY
|
|
12
|
+
npx altalandi "Make sure to reach out to us today."
|
|
13
|
+
# → Hafðu samband við okkur í dag.
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
PowerShell 7: `$env:ALTALANDI_API_KEY = Read-Host "Altalandi API key" -MaskInput`.
|
|
17
|
+
|
|
18
|
+
`stdout` is the refined text (pipe it anywhere); `stderr` carries the 0–100
|
|
19
|
+
nativeness score, the meaning verdict, every API warning, and the diff.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm i -g altalandi # then use `altalandi ...`
|
|
25
|
+
# or run without installing: npx altalandi "..."
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For an application dependency, install locally and import the package root:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm i altalandi
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## JavaScript / TypeScript client
|
|
35
|
+
|
|
36
|
+
`refineVerified` requests the meaning gate and throws unless the API returns
|
|
37
|
+
strict `meaningPreserved: true`. It reads `ALTALANDI_API_KEY` automatically:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { refineVerified } from "altalandi";
|
|
41
|
+
|
|
42
|
+
const result = await refineVerified("Make sure to reach out to us today.", {
|
|
43
|
+
domain: "marketing",
|
|
44
|
+
});
|
|
45
|
+
console.log(result.output);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use `refine` when you need to inspect an unverified result yourself, then apply
|
|
49
|
+
the same strict assertion before shipping it:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { assertMeaningVerified, refine } from "altalandi";
|
|
53
|
+
|
|
54
|
+
const result = await refine("Some text", { register: "warm" });
|
|
55
|
+
assertMeaningVerified(result); // throws on both false and null
|
|
56
|
+
console.log(result.output);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Both functions accept `apiKey` (for a secret-manager value), `baseUrl`, and
|
|
60
|
+
`timeoutMs` alongside the request options. The defaults are
|
|
61
|
+
`ALTALANDI_API_KEY`, `https://altalandi.is/api`, and 300,000 ms. API, network,
|
|
62
|
+
timeout, and verification failures throw `AltalandiError` subclasses with a
|
|
63
|
+
stable `code`; errors never include the request text or API key. TypeScript
|
|
64
|
+
declarations ship with the package. The CLI uses the same 300-second request
|
|
65
|
+
deadline, including response-body download.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
altalandi "Some English or Icelandic text."
|
|
71
|
+
echo "some text" | altalandi --domain marketing
|
|
72
|
+
altalandi --json "..." # full JSON response
|
|
73
|
+
altalandi --fast "..." # skip the meaning + nativeness gates
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| flag | default | meaning |
|
|
77
|
+
|------|---------|---------|
|
|
78
|
+
| `--domain` | `general` | `general` \| `marketing` \| `data-analysis` \| `childrens-fiction` |
|
|
79
|
+
| `--register` | `neutral` | `formal` \| `neutral` \| `warm` |
|
|
80
|
+
| `--json` | off | print the full API response |
|
|
81
|
+
| `--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) |
|
|
82
|
+
|
|
83
|
+
## The meaning guarantee
|
|
84
|
+
|
|
85
|
+
Altalandi never silently changes meaning. For Icelandic input where the rewrite
|
|
86
|
+
can't be verified to preserve meaning, it returns the **original unchanged** and
|
|
87
|
+
says so on `stderr` (or `meaningPreserved:false` with `--json`). Check it before
|
|
88
|
+
trusting the output in an automated pipeline. Only `meaningPreserved:true` is a
|
|
89
|
+
verified pass; `null` means the gate was skipped or did not complete. `--fast`
|
|
90
|
+
deliberately skips the gate, so its result is unverified by design.
|
|
91
|
+
|
|
92
|
+
Without `--fast`, a `null` meaning verdict is printed as **MEANING UNVERIFIED**:
|
|
93
|
+
the check did not run or could not complete, so review the output before use.
|
|
94
|
+
With `--fast`, `null` is expected and is printed as **meaning check skipped**.
|
|
95
|
+
API warnings are always printed on `stderr`; under `--json` they remain in the
|
|
96
|
+
unchanged `warnings` array.
|
|
97
|
+
|
|
98
|
+
For automation, a successful HTTP response is not enough: without `--fast`, the
|
|
99
|
+
CLI prints the text or JSON response and then exits with status **3** when
|
|
100
|
+
`meaningPreserved` is `false` or `null`. Only strict `true` exits 0. `--fast` is
|
|
101
|
+
the explicit verification opt-out and exits 0 after a successful API response.
|
|
102
|
+
|
|
103
|
+
| exit | meaning |
|
|
104
|
+
|------|---------|
|
|
105
|
+
| `0` | verified success, or successful `--fast` request |
|
|
106
|
+
| `1` | API or network failure |
|
|
107
|
+
| `2` | missing input/key or invalid CLI usage |
|
|
108
|
+
| `3` | response printed, but meaning was false or unverified |
|
|
109
|
+
|
|
110
|
+
## Config
|
|
111
|
+
|
|
112
|
+
| env | default | purpose |
|
|
113
|
+
|-----|---------|---------|
|
|
114
|
+
| `ALTALANDI_API_KEY` | — | your key (Bearer). Mint at https://altalandi.is/portal |
|
|
115
|
+
| `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
|
|
116
|
+
|
|
117
|
+
MIT licensed. Portal keys share a daily account-level billable-word budget;
|
|
118
|
+
operator-issued keys remain per-key, and key rotation does not reset account
|
|
119
|
+
usage. The request's text and glossary fields are charged at the effective billing-tier
|
|
120
|
+
weight. Each whitespace-delimited token is at least one unit; an unbroken token
|
|
121
|
+
uses one unit per 20 characters, rounded up.
|
package/altalandi.mjs
CHANGED
|
@@ -1,87 +1,122 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// altalandi — turn English or stiff/AI Icelandic into native Icelandic from
|
|
3
|
-
// your terminal. Zero dependencies (Node >= 18 has global fetch).
|
|
4
|
-
//
|
|
5
|
-
// export ALTALANDI_API_KEY=... # get one at https://altalandi.is
|
|
6
|
-
// altalandi "Make sure to reach out to us today."
|
|
7
|
-
// echo "some text" | altalandi --domain marketing
|
|
8
|
-
//
|
|
9
|
-
// stdout = the refined text (so you can pipe it); stderr = nativeness score,
|
|
10
|
-
// meaning verdict, and the diff.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
else if (a === "--
|
|
21
|
-
else if (a === "--
|
|
22
|
-
else if (a === "--
|
|
23
|
-
else if (a === "--
|
|
24
|
-
else
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
altalandi
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
--
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
process.stdin.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// altalandi — turn English or stiff/AI Icelandic into native Icelandic from
|
|
3
|
+
// your terminal. Zero dependencies (Node >= 18 has global fetch).
|
|
4
|
+
//
|
|
5
|
+
// export ALTALANDI_API_KEY=... # get one at https://altalandi.is
|
|
6
|
+
// altalandi "Make sure to reach out to us today."
|
|
7
|
+
// echo "some text" | altalandi --domain marketing
|
|
8
|
+
//
|
|
9
|
+
// stdout = the refined text (so you can pipe it); stderr = nativeness score,
|
|
10
|
+
// meaning verdict, API warnings, and the diff.
|
|
11
|
+
|
|
12
|
+
import { refine } from "./client.mjs";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const opts = { domain: "general", register: "neutral", json: false, fast: false };
|
|
16
|
+
const positional = [];
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const a = args[i];
|
|
19
|
+
if (a === "-h" || a === "--help") { help(); process.exit(0); }
|
|
20
|
+
else if (a === "--json") opts.json = true;
|
|
21
|
+
else if (a === "--fast") opts.fast = true;
|
|
22
|
+
else if (a === "--domain") opts.domain = args[++i];
|
|
23
|
+
else if (a === "--register") opts.register = args[++i];
|
|
24
|
+
else if (a === "--base-url") opts.baseUrl = args[++i];
|
|
25
|
+
else if (a === "--key") opts.key = args[++i];
|
|
26
|
+
else positional.push(a);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function help() {
|
|
30
|
+
console.log(`altalandi — native Icelandic from your terminal
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
altalandi "text to refine"
|
|
34
|
+
echo "text" | altalandi
|
|
35
|
+
altalandi --domain marketing --register warm "text"
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
--domain general | marketing | data-analysis | childrens-fiction (default general)
|
|
39
|
+
--register formal | neutral | warm (default neutral)
|
|
40
|
+
--fast no polish, no nativeness score, no meaning gate — normally one writer call
|
|
41
|
+
(structured input can still add a structure-preserving retry)
|
|
42
|
+
--json print the full JSON response
|
|
43
|
+
--base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
|
|
44
|
+
|
|
45
|
+
stdout = refined text; stderr = nativeness / meaning / warnings / diff.
|
|
46
|
+
Without --fast, false/null meaning verification exits 3 after printing the response.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readStdin() {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
if (process.stdin.isTTY) return resolve("");
|
|
52
|
+
let d = "";
|
|
53
|
+
process.stdin.setEncoding("utf8");
|
|
54
|
+
process.stdin.on("data", (c) => (d += c));
|
|
55
|
+
process.stdin.on("end", () => resolve(d));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || "https://altalandi.is/api").replace(/\/$/, "");
|
|
60
|
+
const key = opts.key || process.env.ALTALANDI_API_KEY || process.env.ALTALANDI_KEY;
|
|
61
|
+
|
|
62
|
+
if (opts.key) {
|
|
63
|
+
console.error("⚠ --key is deprecated because command arguments can leak through shell history/process listings; load ALTALANDI_API_KEY instead.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const text = (positional.join(" ").trim()) || (await readStdin()).trim();
|
|
67
|
+
if (!text) { console.error("No input. Pass text as an argument or pipe via stdin. (altalandi --help)"); process.exit(2); }
|
|
68
|
+
if (!key) { console.error("No API key. Load ALTALANDI_API_KEY securely (masked setup: https://altalandi.is/portal)."); process.exit(2); }
|
|
69
|
+
|
|
70
|
+
const requestOptions = {
|
|
71
|
+
apiKey: key,
|
|
72
|
+
baseUrl,
|
|
73
|
+
domain: opts.domain,
|
|
74
|
+
register: opts.register,
|
|
75
|
+
};
|
|
76
|
+
// --fast disables every optional quality/verification pass. The writer can
|
|
77
|
+
// still retry once when it drops document structure, as documented in --help.
|
|
78
|
+
// Name every switch explicitly so a future server-default change cannot make
|
|
79
|
+
// this client silently slower.
|
|
80
|
+
if (opts.fast) {
|
|
81
|
+
requestOptions.preserveMeaning = false;
|
|
82
|
+
requestOptions.scoreNativeness = false;
|
|
83
|
+
requestOptions.onlyImprove = false;
|
|
84
|
+
requestOptions.polishPass = false;
|
|
85
|
+
requestOptions.scoring = "none";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// refine() owns a single 300-second AbortController deadline across fetch
|
|
90
|
+
// and response-body parsing, matching the hosted package API.
|
|
91
|
+
const data = await refine(text, requestOptions);
|
|
92
|
+
const verificationFailed = !opts.fast && data.meaningPreserved !== true;
|
|
93
|
+
|
|
94
|
+
if (opts.json) {
|
|
95
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
96
|
+
if (verificationFailed) {
|
|
97
|
+
console.error("Meaning verification did not pass; JSON was printed, exiting with status 3.");
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
process.stdout.write(data.output + "\n");
|
|
101
|
+
const q = data.quality || {};
|
|
102
|
+
const meta = [];
|
|
103
|
+
if (q.nativenessScore != null) meta.push(`nativeness ${q.nativenessScore}/100`);
|
|
104
|
+
if (data.meaningPreserved === false) meta.push("⚠ MEANING DRIFT — returned the original");
|
|
105
|
+
else if (data.meaningPreserved === true) meta.push("meaning preserved");
|
|
106
|
+
else if (opts.fast) meta.push("meaning check skipped (--fast)");
|
|
107
|
+
else meta.push("⚠ MEANING UNVERIFIED — review before use");
|
|
108
|
+
if (meta.length) console.error("· " + meta.join(" · "));
|
|
109
|
+
for (const warning of Array.isArray(data.warnings) ? data.warnings : []) {
|
|
110
|
+
console.error(`⚠ ${warning}`);
|
|
111
|
+
}
|
|
112
|
+
for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// stdout/JSON is intentionally written first so callers can inspect the
|
|
116
|
+
// server response, but guarded automation must not accept null/false as a
|
|
117
|
+
// verified success. --fast is the explicit opt-out from this contract.
|
|
118
|
+
if (verificationFailed) process.exitCode = 3;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error("Request failed:", e.message);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export type SourceLang = "auto" | "en" | "is";
|
|
2
|
+
export type Domain = "general" | "marketing" | "data-analysis" | "childrens-fiction";
|
|
3
|
+
export type Register = "formal" | "neutral" | "warm";
|
|
4
|
+
export type WriterTier = "standard" | "max";
|
|
5
|
+
export type ScoringTier = "none" | "fast" | "rigorous";
|
|
6
|
+
|
|
7
|
+
export interface GlossaryTerm {
|
|
8
|
+
en: string;
|
|
9
|
+
is: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FetchResponseLike {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
status: number;
|
|
16
|
+
statusText?: string;
|
|
17
|
+
text(): Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type FetchLike = (
|
|
21
|
+
url: string,
|
|
22
|
+
init: {
|
|
23
|
+
method: string;
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
body: string;
|
|
26
|
+
signal: any;
|
|
27
|
+
},
|
|
28
|
+
) => Promise<FetchResponseLike>;
|
|
29
|
+
|
|
30
|
+
export interface RefineOptions {
|
|
31
|
+
sourceLang?: SourceLang;
|
|
32
|
+
domain?: Domain;
|
|
33
|
+
register?: Register;
|
|
34
|
+
preserveMeaning?: boolean;
|
|
35
|
+
scoreNativeness?: boolean;
|
|
36
|
+
/** IS-refine: keep the original unless improvement is verified. */
|
|
37
|
+
onlyImprove?: boolean;
|
|
38
|
+
/** Optional EN→IS monolingual polish pass; false by default. */
|
|
39
|
+
polishPass?: boolean;
|
|
40
|
+
writer?: WriterTier;
|
|
41
|
+
scoring?: ScoringTier;
|
|
42
|
+
glossary?: GlossaryTerm[];
|
|
43
|
+
returnDiffs?: boolean;
|
|
44
|
+
/** Defaults to ALTALANDI_API_KEY. Never put a key in source control. */
|
|
45
|
+
apiKey?: string;
|
|
46
|
+
/** Defaults to https://altalandi.is/api. */
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
/** Per-request timeout in milliseconds; defaults to 300000. */
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
/** Optional fetch-compatible implementation, useful for controlled runtimes and tests. */
|
|
51
|
+
fetch?: FetchLike;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RefineDiff {
|
|
55
|
+
before: string;
|
|
56
|
+
after: string;
|
|
57
|
+
layer: "L1" | "L2" | "L3";
|
|
58
|
+
reason: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RefineQuality {
|
|
62
|
+
nativenessScore: number | null;
|
|
63
|
+
translationeseTells: string[];
|
|
64
|
+
iceBertScore: number | null;
|
|
65
|
+
morphologyOk: boolean | null;
|
|
66
|
+
calqueFlags: string[];
|
|
67
|
+
englishRemnants: string[];
|
|
68
|
+
encodingArtifacts: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RefineResult {
|
|
72
|
+
id: string;
|
|
73
|
+
output: string;
|
|
74
|
+
sourceLang: "en" | "is";
|
|
75
|
+
diffs: RefineDiff[];
|
|
76
|
+
quality: RefineQuality;
|
|
77
|
+
/** Only strict true is a completed verification; null means skipped/unavailable. */
|
|
78
|
+
meaningPreserved: boolean | null;
|
|
79
|
+
meaningNotes: string;
|
|
80
|
+
modelUsed: string;
|
|
81
|
+
tier?: { writer: WriterTier; scoring: ScoringTier };
|
|
82
|
+
billedTier?: { writer: WriterTier; scoring: ScoringTier };
|
|
83
|
+
warnings: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type VerifiedRefineResult = RefineResult & { meaningPreserved: true };
|
|
87
|
+
|
|
88
|
+
export interface AltalandiErrorOptions {
|
|
89
|
+
code?: string;
|
|
90
|
+
status?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class AltalandiError extends Error {
|
|
94
|
+
readonly code: string;
|
|
95
|
+
readonly status?: number;
|
|
96
|
+
constructor(message: string, options?: AltalandiErrorOptions);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class MeaningVerificationError extends AltalandiError {
|
|
100
|
+
readonly verdict: false | null;
|
|
101
|
+
constructor(verdict: boolean | null | undefined);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Refine text and return the full hosted API response. */
|
|
105
|
+
export function refine(text: string, options?: RefineOptions): Promise<RefineResult>;
|
|
106
|
+
|
|
107
|
+
/** Refine text and throw unless meaningPreserved is strict true. */
|
|
108
|
+
export function refineVerified(text: string, options?: RefineOptions): Promise<VerifiedRefineResult>;
|
|
109
|
+
|
|
110
|
+
/** Narrow an existing result to one with a verified meaning verdict. */
|
|
111
|
+
export function assertMeaningVerified(
|
|
112
|
+
result: RefineResult,
|
|
113
|
+
): asserts result is VerifiedRefineResult;
|
package/client.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const DEFAULT_BASE_URL = "https://altalandi.is/api";
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
3
|
+
const REQUEST_FIELDS = [
|
|
4
|
+
"sourceLang",
|
|
5
|
+
"domain",
|
|
6
|
+
"register",
|
|
7
|
+
"preserveMeaning",
|
|
8
|
+
"scoreNativeness",
|
|
9
|
+
"onlyImprove",
|
|
10
|
+
"polishPass",
|
|
11
|
+
"writer",
|
|
12
|
+
"scoring",
|
|
13
|
+
"glossary",
|
|
14
|
+
"returnDiffs",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export class AltalandiError extends Error {
|
|
18
|
+
constructor(message, { code = "ALTALANDI_ERROR", status } = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "AltalandiError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
if (Number.isInteger(status)) this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MeaningVerificationError extends AltalandiError {
|
|
27
|
+
constructor(verdict) {
|
|
28
|
+
const changed = verdict === false;
|
|
29
|
+
super(
|
|
30
|
+
changed
|
|
31
|
+
? "Altalandi could not preserve the input meaning."
|
|
32
|
+
: "Altalandi did not return a verified meaning verdict.",
|
|
33
|
+
{ code: changed ? "MEANING_NOT_PRESERVED" : "MEANING_UNVERIFIED" },
|
|
34
|
+
);
|
|
35
|
+
this.name = "MeaningVerificationError";
|
|
36
|
+
this.verdict = verdict === false ? false : null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function env(name) {
|
|
41
|
+
if (typeof process === "undefined" || !process.env) return undefined;
|
|
42
|
+
return process.env[name];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cleanText(value, secret = "") {
|
|
46
|
+
if (typeof value !== "string") return "";
|
|
47
|
+
let cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
48
|
+
if (secret) cleaned = cleaned.split(secret).join("[redacted]");
|
|
49
|
+
return cleaned.slice(0, 300);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function errorDetail(payload) {
|
|
53
|
+
const detail = payload?.detail;
|
|
54
|
+
if (typeof detail === "string") return detail;
|
|
55
|
+
if (Array.isArray(detail)) {
|
|
56
|
+
return detail
|
|
57
|
+
.map((entry) => (typeof entry?.msg === "string" ? entry.msg : ""))
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join("; ");
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requestBody(text, options) {
|
|
65
|
+
const body = { text };
|
|
66
|
+
for (const field of REQUEST_FIELDS) {
|
|
67
|
+
if (options[field] !== undefined) body[field] = options[field];
|
|
68
|
+
}
|
|
69
|
+
return body;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Refine English or Icelandic text through the hosted Altalandi API.
|
|
74
|
+
* Client settings (`apiKey`, `baseUrl`, `timeoutMs`, `fetch`) are never sent in
|
|
75
|
+
* the JSON body. The full API result is returned without weakening its verdict.
|
|
76
|
+
*/
|
|
77
|
+
export async function refine(text, options = {}) {
|
|
78
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
79
|
+
throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const explicitKey = options.apiKey;
|
|
83
|
+
const selectedKey = explicitKey === undefined ? env("ALTALANDI_API_KEY") : explicitKey;
|
|
84
|
+
if (selectedKey !== undefined && typeof selectedKey !== "string") {
|
|
85
|
+
throw new AltalandiError("Altalandi apiKey must be a string.", { code: "INVALID_CONFIG" });
|
|
86
|
+
}
|
|
87
|
+
const apiKey = selectedKey?.trim();
|
|
88
|
+
if (!apiKey) {
|
|
89
|
+
throw new AltalandiError(
|
|
90
|
+
"No Altalandi API key. Set ALTALANDI_API_KEY or pass apiKey explicitly.",
|
|
91
|
+
{ code: "MISSING_API_KEY" },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const selectedBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
96
|
+
if (typeof selectedBaseUrl !== "string") {
|
|
97
|
+
throw new AltalandiError("Altalandi baseUrl must be a string.", { code: "INVALID_CONFIG" });
|
|
98
|
+
}
|
|
99
|
+
const baseUrl = selectedBaseUrl.trim().replace(/\/+$/, "");
|
|
100
|
+
if (!baseUrl) {
|
|
101
|
+
throw new AltalandiError("Altalandi baseUrl must not be empty.", { code: "INVALID_CONFIG" });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
105
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
106
|
+
throw new AltalandiError("Altalandi timeoutMs must be a positive number.", {
|
|
107
|
+
code: "INVALID_CONFIG",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
112
|
+
if (typeof fetchImpl !== "function") {
|
|
113
|
+
throw new AltalandiError("This runtime does not provide fetch.", { code: "INVALID_CONFIG" });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
118
|
+
try {
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetchImpl(`${baseUrl}/v1/refine`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: {
|
|
124
|
+
"content-type": "application/json",
|
|
125
|
+
authorization: `Bearer ${apiKey}`,
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify(requestBody(text, options)),
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
if (controller.signal.aborted) {
|
|
132
|
+
throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
|
|
133
|
+
code: "TIMEOUT",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
throw new AltalandiError("Altalandi could not reach the API.", { code: "NETWORK_ERROR" });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let raw = "";
|
|
140
|
+
try {
|
|
141
|
+
raw = await response.text();
|
|
142
|
+
} catch {
|
|
143
|
+
if (controller.signal.aborted) {
|
|
144
|
+
throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
|
|
145
|
+
code: "TIMEOUT",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (response.ok) {
|
|
149
|
+
throw new AltalandiError("Altalandi could not read the API response.", {
|
|
150
|
+
code: "INVALID_RESPONSE",
|
|
151
|
+
status: response.status,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// The HTTP status remains useful when an error response has no readable body.
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let payload;
|
|
158
|
+
if (raw) {
|
|
159
|
+
try {
|
|
160
|
+
payload = JSON.parse(raw);
|
|
161
|
+
} catch {
|
|
162
|
+
if (response.ok) {
|
|
163
|
+
throw new AltalandiError("Altalandi returned an invalid JSON response.", {
|
|
164
|
+
code: "INVALID_RESPONSE",
|
|
165
|
+
status: response.status,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
const status = Number.isInteger(response.status) ? response.status : 0;
|
|
173
|
+
const statusText = cleanText(response.statusText);
|
|
174
|
+
const detail = cleanText(errorDetail(payload), apiKey);
|
|
175
|
+
const suffix = detail || statusText;
|
|
176
|
+
throw new AltalandiError(
|
|
177
|
+
`Altalandi API request failed (${status || "unknown status"})${suffix ? `: ${suffix}` : "."}`,
|
|
178
|
+
{ code: "API_ERROR", status },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload) || typeof payload.output !== "string") {
|
|
183
|
+
throw new AltalandiError("Altalandi returned an invalid response shape.", {
|
|
184
|
+
code: "INVALID_RESPONSE",
|
|
185
|
+
status: response.status,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return payload;
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(timeout);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Assert that an existing result carries a completed, positive meaning check. */
|
|
196
|
+
export function assertMeaningVerified(result) {
|
|
197
|
+
if (result?.meaningPreserved !== true) {
|
|
198
|
+
throw new MeaningVerificationError(result?.meaningPreserved);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Request meaning verification and reject unless the API returns strict true. */
|
|
204
|
+
export async function refineVerified(text, options = {}) {
|
|
205
|
+
const result = await refine(text, { ...options, preserveMeaning: true });
|
|
206
|
+
assertMeaningVerified(result);
|
|
207
|
+
return result;
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "altalandi",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
5
|
-
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "altalandi",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Hosted Altalandi API client and CLI for native-sounding Icelandic with meaning verification.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./client.mjs",
|
|
7
|
+
"types": "./client.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./client.d.ts",
|
|
11
|
+
"import": "./client.mjs",
|
|
12
|
+
"default": "./client.mjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"altalandi": "altalandi.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"altalandi.mjs",
|
|
20
|
+
"client.mjs",
|
|
21
|
+
"client.d.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test/cli.test.mjs test/client.test.mjs"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"icelandic",
|
|
30
|
+
"íslenska",
|
|
31
|
+
"translation",
|
|
32
|
+
"translationese",
|
|
33
|
+
"nlp",
|
|
34
|
+
"altalandi",
|
|
35
|
+
"cli",
|
|
36
|
+
"i18n",
|
|
37
|
+
"localization",
|
|
38
|
+
"language"
|
|
39
|
+
],
|
|
40
|
+
"homepage": "https://altalandi.is",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/pbidhjorvar/ritvit.git",
|
|
44
|
+
"directory": "tools/cli"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/pbidhjorvar/ritvit/issues"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|