evals-lab 0.1.2 → 0.1.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 +60 -1
- package/bin/evals-lab.js +23 -4
- package/lab/VERSION +1 -0
- package/lab/server.py +90 -7
- package/lab/web/dist/assets/main-61NS6C4m.js +18 -0
- package/lab/web/dist/index.html +1 -1
- package/package.json +1 -1
- package/lab/web/dist/assets/main-CpVssvrb.js +0 -18
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ npm install -g evals-lab
|
|
|
13
13
|
evals-lab
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Then open <http://
|
|
16
|
+
Then open <http://localhost:8080>. A new lab opens on two demo datasets, each
|
|
17
17
|
with its images and a starter pipeline. The first start takes a little longer
|
|
18
18
|
than later ones while it installs them.
|
|
19
19
|
|
|
@@ -61,12 +61,71 @@ evals-lab [--port <n>] [--host <address>] [--data-dir <dir>]
|
|
|
61
61
|
| `--data-dir` | `DATA_DIR` | the directory above |
|
|
62
62
|
| | `LAB_PASSWORD` | none |
|
|
63
63
|
| | `OLLAMA_URL` | `http://127.0.0.1:11434` |
|
|
64
|
+
| | `M365_CLIENT_ID`, `M365_TENANT` | none; see Power Automate below |
|
|
64
65
|
| | `PYTHON` | `py -3`, `python` or `python3`, whichever is found |
|
|
65
66
|
|
|
66
67
|
`OLLAMA_URL` is only the connection a new lab starts with. Every other
|
|
67
68
|
connection — an OpenAI-compatible endpoint, Anthropic, llama.cpp — is a Setup
|
|
68
69
|
profile you add in the page.
|
|
69
70
|
|
|
71
|
+
## Power Automate (Microsoft 365)
|
|
72
|
+
|
|
73
|
+
The lab can read a Power Automate cloud flow, and its run history, from your
|
|
74
|
+
organisation's Microsoft 365 tenant, then grade the flow's HTTP step against
|
|
75
|
+
the same model and prompt. The sign-in stays in your browser tab: the lab's
|
|
76
|
+
server never receives a Microsoft token.
|
|
77
|
+
|
|
78
|
+
It needs a work or school account. Microsoft retired cloud flows for personal
|
|
79
|
+
accounts (outlook.com, hotmail.com) in July 2025, so a personal account cannot
|
|
80
|
+
be used. For a small business, a trial or a Microsoft 365 Developer Program
|
|
81
|
+
tenant, follow the same steps as a company.
|
|
82
|
+
|
|
83
|
+
### Register an app in your tenant
|
|
84
|
+
|
|
85
|
+
An administrator of the tenant registers it once, at
|
|
86
|
+
<https://entra.microsoft.com>:
|
|
87
|
+
|
|
88
|
+
1. **App registrations › New registration.**
|
|
89
|
+
- Supported account types: *Accounts in this organizational directory only*.
|
|
90
|
+
- Redirect URI: platform **Single-page application**, value `http://localhost/`.
|
|
91
|
+
Microsoft ignores the port on a `localhost` redirect, so this covers
|
|
92
|
+
`--port` too. A lab served on another address needs that address here
|
|
93
|
+
as well, over `https`.
|
|
94
|
+
2. **API permissions › Add a permission › APIs my organization uses.** Search
|
|
95
|
+
for *Microsoft Flow Service* and add the delegated permission
|
|
96
|
+
**Flows.Read.All**. If the service isn't listed, open
|
|
97
|
+
<https://make.powerautomate.com> once, then search again.
|
|
98
|
+
3. **Grant admin consent** for the tenant.
|
|
99
|
+
4. No client secret or certificate is needed: the lab signs in as a public
|
|
100
|
+
client.
|
|
101
|
+
|
|
102
|
+
### Tell the lab about it
|
|
103
|
+
|
|
104
|
+
In the lab, open **Setup › Connections**, then use the Microsoft 365 row's
|
|
105
|
+
menu › **Configure…**. Enter:
|
|
106
|
+
|
|
107
|
+
| | |
|
|
108
|
+
|---|---|
|
|
109
|
+
| Client ID | the app's *Application (client) ID* |
|
|
110
|
+
| Tenant | the *Directory (tenant) ID*, or one of the tenant's domains |
|
|
111
|
+
|
|
112
|
+
Fill in Tenant for an app registered as above. Left empty, it means any
|
|
113
|
+
organisation's tenant, which only a multitenant app accepts: Microsoft
|
|
114
|
+
refuses the sign-in of a single-tenant app with `AADSTS50194`.
|
|
115
|
+
|
|
116
|
+
Both are saved as you type, and neither is secret. **Sign in** from the same
|
|
117
|
+
menu, then add a Source in the Sources tab with **Add source › Power Automate
|
|
118
|
+
workflow**.
|
|
119
|
+
|
|
120
|
+
To set the app for the whole machine instead, start the lab with
|
|
121
|
+
`M365_CLIENT_ID` and `M365_TENANT` set. Setup then shows those values and
|
|
122
|
+
can't change them.
|
|
123
|
+
|
|
124
|
+
Open the lab at `localhost`, the address `evals-lab` prints. A sign-in begun
|
|
125
|
+
at `127.0.0.1` reloads the page at `localhost`, the address registered above,
|
|
126
|
+
and carries on from there. Anything open in the tab, such as an Add source
|
|
127
|
+
dialog, does not carry over.
|
|
128
|
+
|
|
70
129
|
## Reaching it from another machine
|
|
71
130
|
|
|
72
131
|
By default only your own machine can reach the lab. That is deliberate: the
|
package/bin/evals-lab.js
CHANGED
|
@@ -32,7 +32,7 @@ const USAGE = `Usage: evals-lab [options]
|
|
|
32
32
|
127.0.0.1. Anything but loopback needs LAB_PASSWORD set.
|
|
33
33
|
--data-dir <dir> where the lab keeps its datasets, pipelines, profiles and
|
|
34
34
|
runs. Default: $DATA_DIR, else a per-user directory.
|
|
35
|
-
--version the package's version.
|
|
35
|
+
--version the package's version, and the lab build it was made from.
|
|
36
36
|
--help this.
|
|
37
37
|
|
|
38
38
|
Environment: LAB_PASSWORD puts a password in front of the lab, OLLAMA_URL is
|
|
@@ -53,6 +53,13 @@ function isLoopback(host) {
|
|
|
53
53
|
return host === "localhost" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// The address to open the lab at. Loopback reads as localhost: it is the one
|
|
57
|
+
// http address Entra takes as a sign-in redirect from its portal, on any port,
|
|
58
|
+
// so a Microsoft 365 sign-in works from the address printed (npm/README.md).
|
|
59
|
+
function advertised(host, port) {
|
|
60
|
+
return `http://${host === "127.0.0.1" ? "localhost" : host}:${port}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
// What the command line and the environment ask for, or an Error saying what
|
|
57
64
|
// is wrong with it.
|
|
58
65
|
function options(argv, env = process.env, platform = process.platform, home = os.homedir()) {
|
|
@@ -129,6 +136,16 @@ function answers(url) {
|
|
|
129
136
|
});
|
|
130
137
|
}
|
|
131
138
|
|
|
139
|
+
// What the Home tab's footer says: "0.1.2 (2026.10.01-284)" -- the package,
|
|
140
|
+
// and the Image build of the same commit (npm/build.js).
|
|
141
|
+
function version() {
|
|
142
|
+
try {
|
|
143
|
+
return fs.readFileSync(path.join(LAB, "VERSION"), "utf8").trim();
|
|
144
|
+
} catch {
|
|
145
|
+
return `${require("../package.json").version} (dev)`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
function fail(message) {
|
|
133
150
|
console.error(`evals-lab: ${message}`);
|
|
134
151
|
process.exit(1);
|
|
@@ -138,10 +155,11 @@ async function main() {
|
|
|
138
155
|
let o;
|
|
139
156
|
try { o = options(process.argv.slice(2)); } catch (e) { fail(`${e.message}\n\n${USAGE}`); }
|
|
140
157
|
if (o.help) return console.log(USAGE);
|
|
141
|
-
if (o.version) return console.log(
|
|
158
|
+
if (o.version) return console.log(version());
|
|
142
159
|
if (!fs.existsSync(path.join(LAB, "server.py"))) {
|
|
143
160
|
fail(`no ${path.join(LAB, "server.py")} -- in a checkout the package is staged by \`node npm/build.js\``);
|
|
144
161
|
}
|
|
162
|
+
console.log(`evals-lab ${version()}`);
|
|
145
163
|
const python = findPython(pythonCandidates());
|
|
146
164
|
if (!python.cmd) {
|
|
147
165
|
const floor = PYTHON_FLOOR.join(".");
|
|
@@ -180,11 +198,12 @@ async function main() {
|
|
|
180
198
|
process.on("SIGTERM", () => { stopping = true; child.kill("SIGTERM"); });
|
|
181
199
|
|
|
182
200
|
const address = `http://${o.host}:${o.port}`;
|
|
201
|
+
const open = advertised(o.host, o.port);
|
|
183
202
|
// A first start installs the demo pack before it listens, which takes longer
|
|
184
203
|
// than any later one.
|
|
185
204
|
for (let i = 0; i < 1200; i++) {
|
|
186
205
|
if (await answers(`${address}/healthz`)) {
|
|
187
|
-
console.log(`\nevals-lab is running at ${
|
|
206
|
+
console.log(`\nevals-lab ${version()} is running at ${open}\n data: ${o.dataDir}\n stop: Ctrl+C`);
|
|
188
207
|
return;
|
|
189
208
|
}
|
|
190
209
|
await new Promise(r => setTimeout(r, 250));
|
|
@@ -193,4 +212,4 @@ async function main() {
|
|
|
193
212
|
}
|
|
194
213
|
|
|
195
214
|
if (require.main === module) main();
|
|
196
|
-
module.exports = { dataDir, isLoopback, options, pythonCandidates, findPython, atLeast, PYTHON_FLOOR };
|
|
215
|
+
module.exports = { dataDir, isLoopback, advertised, options, pythonCandidates, findPython, atLeast, PYTHON_FLOOR };
|
package/lab/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.4 (2026.10.01-291)
|
package/lab/server.py
CHANGED
|
@@ -288,9 +288,44 @@ DEFAULT_SOURCE_TYPE = "files"
|
|
|
288
288
|
# The Entra app the page signs in to Microsoft 365 with (docs/power-automate.md
|
|
289
289
|
# § Registering the app). A single-page app has no secret, so both are public
|
|
290
290
|
# and served to the page; the token it gets stays in the browser, and the
|
|
291
|
-
# server never sees one.
|
|
291
|
+
# server never sees one. The deployment's own, when it names one, then the one
|
|
292
|
+
# entered in Setup (MicrosoftApp), then the published app below; with none, a
|
|
293
|
+
# Power Automate workflow cannot be made.
|
|
292
294
|
M365_CLIENT_ID = os.environ.get("M365_CLIENT_ID") or None
|
|
293
295
|
M365_TENANT = os.environ.get("M365_TENANT") or "organizations"
|
|
296
|
+
# The multitenant app published for every lab, a tenant's admin consenting to
|
|
297
|
+
# it once. None until it is registered and its publisher verified: an
|
|
298
|
+
# unverified app from another organisation is one most tenants' users may not
|
|
299
|
+
# consent to, so shipping it earlier would only move the failure to sign-in.
|
|
300
|
+
PUBLIC_M365_CLIENT_ID = None
|
|
301
|
+
|
|
302
|
+
GUID = re.compile(r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}")
|
|
303
|
+
TENANT_DOMAIN = re.compile(r"(?=.{4,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def microsoft_config():
|
|
307
|
+
"""The Microsoft app this lab signs in with, and where it came from."""
|
|
308
|
+
if M365_CLIENT_ID:
|
|
309
|
+
return {"clientId": M365_CLIENT_ID, "tenant": M365_TENANT, "from": "env"}
|
|
310
|
+
kept = MICROSOFT.get() if MICROSOFT is not None else None
|
|
311
|
+
if kept:
|
|
312
|
+
return {**kept, "from": "lab"}
|
|
313
|
+
if PUBLIC_M365_CLIENT_ID:
|
|
314
|
+
return {"clientId": PUBLIC_M365_CLIENT_ID, "tenant": "organizations", "from": "default"}
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def microsoft_problem(client_id, tenant):
|
|
319
|
+
"""Why a client and tenant cannot be this lab's Microsoft app, or None."""
|
|
320
|
+
if not GUID.fullmatch(client_id):
|
|
321
|
+
return "a client ID is a GUID, the app registration's Application (client) ID"
|
|
322
|
+
if tenant.lower() in ("common", "consumers"):
|
|
323
|
+
# Microsoft retired cloud flows for personal accounts on 2025-07-26,
|
|
324
|
+
# so an authority that admits them only admits a sign-in with no flows.
|
|
325
|
+
return "a personal Microsoft account has no Power Automate flows: name a tenant, or organizations"
|
|
326
|
+
if not (tenant == "organizations" or GUID.fullmatch(tenant) or TENANT_DOMAIN.fullmatch(tenant)):
|
|
327
|
+
return "a tenant is its Directory (tenant) ID, one of its domains, or organizations"
|
|
328
|
+
return None
|
|
294
329
|
|
|
295
330
|
# How much of a flow a Source keeps: a definition's size, and how many of its
|
|
296
331
|
# versions -- the documents rule's ten.
|
|
@@ -420,7 +455,7 @@ SOURCE_TYPES["power-automate"]["take"] = take_record
|
|
|
420
455
|
|
|
421
456
|
# The sign-ins a Source type may be made with, and whether this lab has each:
|
|
422
457
|
# a type naming one this lab lacks cannot be made.
|
|
423
|
-
SIGN_INS = {"microsoft": lambda:
|
|
458
|
+
SIGN_INS = {"microsoft": lambda: microsoft_config() is not None}
|
|
424
459
|
|
|
425
460
|
|
|
426
461
|
# The browser stores that follow their user between devices, by their
|
|
@@ -2668,6 +2703,43 @@ class Connections:
|
|
|
2668
2703
|
self.problem = None
|
|
2669
2704
|
|
|
2670
2705
|
|
|
2706
|
+
class MicrosoftApp:
|
|
2707
|
+
"""The Microsoft app entered in Setup: a client ID and a tenant, neither
|
|
2708
|
+
secret, kept so a lab installed rather than deployed needs no environment
|
|
2709
|
+
variable to read a flow."""
|
|
2710
|
+
|
|
2711
|
+
KEYS = ("m365.clientId", "m365.tenant")
|
|
2712
|
+
|
|
2713
|
+
def __init__(self, store: Store):
|
|
2714
|
+
self.store = store
|
|
2715
|
+
with store.lock, closing(sqlite3.connect(store.path)) as db, db:
|
|
2716
|
+
db.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
2717
|
+
|
|
2718
|
+
def get(self):
|
|
2719
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2720
|
+
rows = dict(db.execute("SELECT key, value FROM settings WHERE key IN (?, ?)", self.KEYS))
|
|
2721
|
+
client = rows.get(self.KEYS[0])
|
|
2722
|
+
return {"clientId": client, "tenant": rows.get(self.KEYS[1]) or "organizations"} if client else None
|
|
2723
|
+
|
|
2724
|
+
def set(self, client_id, tenant):
|
|
2725
|
+
"""Keeps them, or with no client ID forgets them: (kept, error)."""
|
|
2726
|
+
if not isinstance(client_id, str) or not isinstance(tenant, str):
|
|
2727
|
+
return None, (400, "a Microsoft app is { clientId, tenant }")
|
|
2728
|
+
client_id, tenant = client_id.strip(), tenant.strip() or "organizations"
|
|
2729
|
+
if tenant.lower() == "organizations":
|
|
2730
|
+
tenant = "organizations"
|
|
2731
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2732
|
+
if not client_id:
|
|
2733
|
+
db.execute("DELETE FROM settings WHERE key IN (?, ?)", self.KEYS)
|
|
2734
|
+
return None, None
|
|
2735
|
+
bad = microsoft_problem(client_id, tenant)
|
|
2736
|
+
if bad:
|
|
2737
|
+
return None, (400, bad)
|
|
2738
|
+
db.executemany("INSERT INTO settings VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
2739
|
+
zip(self.KEYS, (client_id, tenant)))
|
|
2740
|
+
return self.get(), None
|
|
2741
|
+
|
|
2742
|
+
|
|
2671
2743
|
class Plugins:
|
|
2672
2744
|
"""The installed plugins: their files on disk, their manifests in the store."""
|
|
2673
2745
|
|
|
@@ -3873,10 +3945,11 @@ if DATA_DIR:
|
|
|
3873
3945
|
PACKS = Packs(STORE)
|
|
3874
3946
|
PLUGINS = Plugins(STORE)
|
|
3875
3947
|
CONNECTIONS = Connections(STORE)
|
|
3948
|
+
MICROSOFT = MicrosoftApp(STORE)
|
|
3876
3949
|
QUEUE = Queue(STORE)
|
|
3877
3950
|
QUEUE.prompts = PROMPTS
|
|
3878
3951
|
else:
|
|
3879
|
-
STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = QUEUE = None
|
|
3952
|
+
STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = QUEUE = None
|
|
3880
3953
|
|
|
3881
3954
|
|
|
3882
3955
|
class NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
@@ -4030,9 +4103,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4030
4103
|
# The lab's own limits, for the page to disable rather than
|
|
4031
4104
|
# hard-code: how many scenarios a run may have.
|
|
4032
4105
|
# And the Microsoft app a flow is read with, when there is one.
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4106
|
+
# The app may be entered in Setup unless the deployment names
|
|
4107
|
+
# its own, and only in a lab with a store to keep it in.
|
|
4108
|
+
return self._json(200, {"scenarioCap": SCENARIO_CAP, "microsoft": microsoft_config(),
|
|
4109
|
+
"microsoftEditable": MICROSOFT is not None and not M365_CLIENT_ID})
|
|
4036
4110
|
if path == "/api/state":
|
|
4037
4111
|
if STORE is None:
|
|
4038
4112
|
return self._send(404, b"not found", "text/plain")
|
|
@@ -4307,6 +4381,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4307
4381
|
parts = self.path.split("?", 1)[0].split("/")
|
|
4308
4382
|
if len(parts) == 4 and parts[:3] == ["", "api", "prompts"] and PROMPTS is not None:
|
|
4309
4383
|
return self._prompts_put(parts[3])
|
|
4384
|
+
if parts == ["", "api", "microsoft"] and MICROSOFT is not None:
|
|
4385
|
+
if M365_CLIENT_ID:
|
|
4386
|
+
return self._json(409, {"error": "this lab's Microsoft app is its deployment's (M365_CLIENT_ID)"})
|
|
4387
|
+
payload = self._payload() or {}
|
|
4388
|
+
_, err = MICROSOFT.set(payload.get("clientId"), payload.get("tenant", ""))
|
|
4389
|
+
if err:
|
|
4390
|
+
code, message = err
|
|
4391
|
+
return self._json(code, {"error": message})
|
|
4392
|
+
return self._json(200, {"microsoft": microsoft_config()})
|
|
4310
4393
|
if len(parts) == 5 and parts[:3] == ["", "api", "sources"] and parts[4] == "definition" \
|
|
4311
4394
|
and SOURCES is not None:
|
|
4312
4395
|
payload, err = self._dataset_payload(DEFINITION_CAP * 2, "a definition")
|
|
@@ -4857,7 +4940,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4857
4940
|
# made in a lab without that sign-in.
|
|
4858
4941
|
sign_in = (SOURCE_TYPES.get(kind) or {}).get("signIn")
|
|
4859
4942
|
if sign_in and not SIGN_INS.get(sign_in, lambda: False)():
|
|
4860
|
-
return self._json(400, {"error": "this lab has no Microsoft app to sign in with (
|
|
4943
|
+
return self._json(400, {"error": "this lab has no Microsoft app to sign in with (Setup › Connections)"
|
|
4861
4944
|
if sign_in == "microsoft" else f"this lab has no {sign_in} sign-in"})
|
|
4862
4945
|
source, err = SOURCES.create(payload.get("name"), kind, payload.get("config"))
|
|
4863
4946
|
if err:
|