evals-lab 0.1.3 → 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 CHANGED
@@ -13,7 +13,7 @@ npm install -g evals-lab
13
13
  evals-lab
14
14
  ```
15
15
 
16
- Then open <http://127.0.0.1:8080>. A new lab opens on two demo datasets, each
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
@@ -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()) {
@@ -191,11 +198,12 @@ async function main() {
191
198
  process.on("SIGTERM", () => { stopping = true; child.kill("SIGTERM"); });
192
199
 
193
200
  const address = `http://${o.host}:${o.port}`;
201
+ const open = advertised(o.host, o.port);
194
202
  // A first start installs the demo pack before it listens, which takes longer
195
203
  // than any later one.
196
204
  for (let i = 0; i < 1200; i++) {
197
205
  if (await answers(`${address}/healthz`)) {
198
- console.log(`\nevals-lab ${version()} is running at ${address}\n data: ${o.dataDir}\n stop: Ctrl+C`);
206
+ console.log(`\nevals-lab ${version()} is running at ${open}\n data: ${o.dataDir}\n stop: Ctrl+C`);
199
207
  return;
200
208
  }
201
209
  await new Promise(r => setTimeout(r, 250));
@@ -204,4 +212,4 @@ async function main() {
204
212
  }
205
213
 
206
214
  if (require.main === module) main();
207
- 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 CHANGED
@@ -1 +1 @@
1
- 0.1.3 (2026.10.01-289)
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. Unset, a Power Automate workflow cannot be made.
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: M365_CLIENT_ID is not None}
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
- return self._json(200, {"scenarioCap": SCENARIO_CAP,
4034
- "microsoft": {"clientId": M365_CLIENT_ID, "tenant": M365_TENANT}
4035
- if M365_CLIENT_ID else None})
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 (M365_CLIENT_ID)"
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: