evals-lab 0.1.3 → 0.2.0

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/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
@@ -1441,7 +1476,7 @@ def scenario_ref(sc, i):
1441
1476
  what version 4's upgrade gives one, by position."""
1442
1477
  sid = sc.get("id") if isinstance(sc, dict) else None
1443
1478
  name = (sc.get("name") or "").strip() if isinstance(sc, dict) and isinstance(sc.get("name"), str) else ""
1444
- return (sid if isinstance(sid, str) and sid else f"s{i + 1}"), (name or f"Scenario {i + 1}")
1479
+ return (sid if isinstance(sid, str) and sid else f"s{i + 1}"), (name or f"Target {i + 1}")
1445
1480
 
1446
1481
 
1447
1482
  class Prompts:
@@ -1597,27 +1632,28 @@ class Prompts:
1597
1632
  recorded once: a second call for the same run changes nothing."""
1598
1633
  # The caller's connection: a Row still reads by index, as it does.
1599
1634
  db.row_factory = sqlite3.Row
1600
- for i, sc in enumerate(run.get("scenarios") or []):
1601
- if not isinstance(sc, dict):
1602
- continue
1635
+ for i, sc, k, cell in cells_of(run):
1603
1636
  sid, sname = scenario_ref(sc, i)
1604
- for k, cell in enumerate(sc.get("stages") or []):
1605
- text = cell.get("prompt") if isinstance(cell, dict) else None
1606
- if not isinstance(text, str) or not text.strip():
1607
- continue
1608
- pid = version = None
1609
- ref = cell.get("from")
1610
- if isinstance(ref, dict) and isinstance(ref.get("id"), str) and self._live(db, ref["id"]):
1611
- pid = ref["id"]
1612
- hit = db.execute("SELECT version FROM prompt_versions WHERE prompt_id = ? AND text = ? "
1613
- "ORDER BY version DESC LIMIT 1", (pid, text)).fetchone()
1614
- version = hit[0] if hit else self._cut(db, pid, text)
1615
- if pid is None:
1616
- hit = self._matching(db, text)
1617
- pid, version = (hit[0], hit[1]) if hit else (self._insert(db, "", text), 1)
1618
- db.execute("INSERT OR IGNORE INTO prompt_uses (prompt_id, version, run_id, scenario_id, "
1619
- "scenario_name, job, at) VALUES (?, ?, ?, ?, ?, ?, ?)",
1620
- (pid, version, rid, sid, sname, k, at))
1637
+ # Echo's words are no wording under test: the item is its reply,
1638
+ # and over Prompt only the words are the reply itself.
1639
+ if isinstance(cell, dict) and cell.get("type") in UNRECORDED_STEPS:
1640
+ continue
1641
+ text = cell.get("prompt") if isinstance(cell, dict) else None
1642
+ if not isinstance(text, str) or not text.strip():
1643
+ continue
1644
+ pid = version = None
1645
+ ref = cell.get("from")
1646
+ if isinstance(ref, dict) and isinstance(ref.get("id"), str) and self._live(db, ref["id"]):
1647
+ pid = ref["id"]
1648
+ hit = db.execute("SELECT version FROM prompt_versions WHERE prompt_id = ? AND text = ? "
1649
+ "ORDER BY version DESC LIMIT 1", (pid, text)).fetchone()
1650
+ version = hit[0] if hit else self._cut(db, pid, text)
1651
+ if pid is None:
1652
+ hit = self._matching(db, text)
1653
+ pid, version = (hit[0], hit[1]) if hit else (self._insert(db, "", text), 1)
1654
+ db.execute("INSERT OR IGNORE INTO prompt_uses (prompt_id, version, run_id, scenario_id, "
1655
+ "scenario_name, job, at) VALUES (?, ?, ?, ?, ?, ?, ?)",
1656
+ (pid, version, rid, sid, sname, k, at))
1621
1657
 
1622
1658
  def backfill(self, runs):
1623
1659
  """The runs from before the library, read into it once. [runs] is
@@ -2542,16 +2578,56 @@ def registered_ids(manifest: dict) -> set:
2542
2578
  # ---- Connections: the lab's grants to outside services (#127) --------------
2543
2579
  #
2544
2580
  # One Google grant per lab, for Sources that read a Drive folder. The OAuth
2545
- # client is the deployment's (env vars), never the repository's or the
2546
- # store's; the refresh token the grant yields is the store's, in a table of
2547
- # its own, so /api/state -- which serves the synced documents to the page --
2548
- # can never carry it. The page is told only whether there is a grant and
2549
- # whose. The hosts are fixed here, not chosen by anything a request carries,
2550
- # and reached through OPENER: no redirects, no proxy from the environment.
2581
+ # client it signs in with is the deployment's (GOOGLE_* below), then the one
2582
+ # entered in Setup (GoogleApp), then the published app (PUBLIC_GOOGLE_APP),
2583
+ # as Microsoft's is (docs/power-automate.md). The refresh token the grant
2584
+ # yields is the store's, in a table of its own, so /api/state -- which serves
2585
+ # the synced documents to the page -- can never carry it, and nor can the
2586
+ # client's secret. The page is told only whether there is a grant and whose.
2587
+ # The hosts are fixed here, not chosen by anything a request carries, and
2588
+ # reached through OPENER: no redirects, no proxy from the environment.
2551
2589
  GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID") or None
2552
2590
  GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET") or None
2553
2591
  GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY") or None
2554
- GOOGLE_APP_ID = os.environ.get("GOOGLE_APP_ID") or None
2592
+ # A deployment reached at its own address signs in with a Web application
2593
+ # client, whose redirect it registers; "installed" is Google's Desktop app.
2594
+ GOOGLE_CLIENT_TYPE = os.environ.get("GOOGLE_CLIENT_TYPE") or "web"
2595
+ GOOGLE_CLIENT_TYPES = ("installed", "web")
2596
+ # An OAuth client's id is its project's number, a dash, and Google's own
2597
+ # suffix: the number is the app id the Picker is told, so it is never asked.
2598
+ GOOGLE_CLIENT = re.compile(r"(\d+)-[0-9a-z]+\.apps\.googleusercontent\.com")
2599
+ GOOGLE_KEY = re.compile(r"[A-Za-z0-9_-]{30,60}")
2600
+
2601
+
2602
+ def read_published_google(path):
2603
+ """The published app from the file the npm package's build writes
2604
+ beside server.py: (app, None), (None, None) when there is no file, or
2605
+ (None, why) for a file that is not one."""
2606
+ if not path.is_file():
2607
+ return None, None
2608
+ try:
2609
+ got = json.loads(path.read_text("utf-8"))
2610
+ except (OSError, ValueError):
2611
+ return None, f"{path} is not JSON"
2612
+ if not (isinstance(got, dict) and isinstance(got.get("clientId"), str)
2613
+ and GOOGLE_CLIENT.fullmatch(got["clientId"]) and isinstance(got.get("clientSecret"), str)
2614
+ and got["clientSecret"] and isinstance(got.get("apiKey"), str) and GOOGLE_KEY.fullmatch(got["apiKey"])):
2615
+ return None, f"{path} is not a Google app: {{ clientId, clientSecret, apiKey }}"
2616
+ return {"clientId": got["clientId"], "clientSecret": got["clientSecret"],
2617
+ "apiKey": got["apiKey"], "clientType": "installed"}, None
2618
+
2619
+
2620
+ # The Desktop-app client published for every lab, so a lab installed from
2621
+ # npm signs in with nothing to set up. A Desktop client signs in back to any
2622
+ # port on this machine with no redirect registered, and Google does not hold
2623
+ # its secret to be one: it is in every copy of the package by design. It
2624
+ # serves only a lab reached on this machine -- one reached at its own address
2625
+ # brings a Web application client of its own. Never in the repository: the
2626
+ # package's build writes it beside server.py from the Package workflow's
2627
+ # PUBLIC_GOOGLE_APP secret (docs/google-drive.md § The published app), and
2628
+ # a checkout or the image has none.
2629
+ GOOGLE_APP_FILE = HERE / "google-app.json"
2630
+ PUBLIC_GOOGLE_APP, PUBLIC_GOOGLE_PROBLEM = read_published_google(GOOGLE_APP_FILE)
2555
2631
  # drive.file: only what the user picks in Google's Picker, and non-sensitive,
2556
2632
  # so the app can be published without Google's verification (#127).
2557
2633
  GOOGLE_SCOPE = "https://www.googleapis.com/auth/drive.file"
@@ -2565,6 +2641,94 @@ GOOGLE_CALLBACK = "/api/connections/google/callback"
2565
2641
  GOOGLE_STATE_SECONDS = 600
2566
2642
 
2567
2643
 
2644
+ def google_app():
2645
+ """The Google app this lab signs in with -- secret included, for the
2646
+ server's own use only -- and where it came from; None with none."""
2647
+ if GOOGLE_CLIENT_ID:
2648
+ return {"clientId": GOOGLE_CLIENT_ID, "clientSecret": GOOGLE_CLIENT_SECRET,
2649
+ "apiKey": GOOGLE_API_KEY, "clientType": GOOGLE_CLIENT_TYPE, "from": "env"}
2650
+ kept = GOOGLE.get() if GOOGLE is not None else None
2651
+ if kept:
2652
+ return {**kept, "from": "lab"}
2653
+ if PUBLIC_GOOGLE_APP:
2654
+ return {**PUBLIC_GOOGLE_APP, "from": "default"}
2655
+ return None
2656
+
2657
+
2658
+ def google_public(app):
2659
+ """What the page is told of an app: everything but its secret."""
2660
+ if app is None:
2661
+ return None
2662
+ m = GOOGLE_CLIENT.fullmatch(app.get("clientId") or "")
2663
+ return {"clientId": app.get("clientId"), "clientType": app.get("clientType"),
2664
+ "apiKey": app.get("apiKey"), "appId": m.group(1) if m else None,
2665
+ "hasSecret": bool(app.get("clientSecret")), "from": app["from"]}
2666
+
2667
+
2668
+ def loopback_origin(origin) -> bool:
2669
+ """Whether a page's origin is this machine: where a Desktop client may
2670
+ send the browser back to."""
2671
+ host = urllib.parse.urlsplit(origin).hostname or ""
2672
+ return host in ("localhost", "::1") or host.startswith("127.")
2673
+
2674
+
2675
+ class GoogleApp:
2676
+ """The Google app entered in Setup: a client and its secret, from the
2677
+ client file Google hands out, and the Picker's key. Kept so a lab with no
2678
+ deployment around it needs no environment variable; the secret is
2679
+ written here and never read back out to the page."""
2680
+
2681
+ KEYS = ("google.clientId", "google.clientSecret", "google.apiKey", "google.clientType")
2682
+
2683
+ def __init__(self, store: Store):
2684
+ self.store = store
2685
+ with store.lock, closing(sqlite3.connect(store.path)) as db, db:
2686
+ db.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
2687
+
2688
+ def _read(self, db):
2689
+ rows = dict(db.execute("SELECT key, value FROM settings WHERE key IN (?, ?, ?, ?)", self.KEYS))
2690
+ return dict(zip(("clientId", "clientSecret", "apiKey", "clientType"),
2691
+ (rows.get(k) or None for k in self.KEYS)))
2692
+
2693
+ def get(self):
2694
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2695
+ got = self._read(db)
2696
+ return {**got, "clientType": got["clientType"] or "installed"} if got["clientId"] else None
2697
+
2698
+ def set(self, payload):
2699
+ """Keeps what is sent, or with no client id forgets it all: (kept,
2700
+ error, whether the client changed). A secret not sent is kept while
2701
+ the client is the same one, and forgotten when it is not: a secret
2702
+ belongs to its client."""
2703
+ if not isinstance(payload, dict):
2704
+ return None, (400, "a Google app is { clientId, clientSecret, apiKey, clientType }"), False
2705
+ client = payload.get("clientId")
2706
+ secret, key = payload.get("clientSecret"), payload.get("apiKey")
2707
+ ctype = payload.get("clientType") or "installed"
2708
+ if not isinstance(client, str) or not all(v is None or isinstance(v, str) for v in (secret, key)):
2709
+ return None, (400, "a Google app is { clientId, clientSecret, apiKey, clientType }"), False
2710
+ client, key = client.strip(), (key or "").strip()
2711
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2712
+ had = self._read(db)
2713
+ changed = (had["clientId"] or "") != client
2714
+ if not client:
2715
+ db.execute("DELETE FROM settings WHERE key IN (?, ?, ?, ?)", self.KEYS)
2716
+ return None, None, changed
2717
+ if not GOOGLE_CLIENT.fullmatch(client):
2718
+ return None, (400, "a client ID ends .apps.googleusercontent.com"), False
2719
+ if key and not GOOGLE_KEY.fullmatch(key):
2720
+ return None, (400, "that is not an API key"), False
2721
+ if ctype not in GOOGLE_CLIENT_TYPES:
2722
+ return None, (400, "a client is a Desktop app or a Web application"), False
2723
+ if secret is None:
2724
+ secret = None if changed else had["clientSecret"]
2725
+ secret = (secret or "").strip()
2726
+ db.execute("DELETE FROM settings WHERE key IN (?, ?, ?, ?)", self.KEYS)
2727
+ db.executemany("INSERT INTO settings VALUES (?, ?)",
2728
+ [(k, v) for k, v in zip(self.KEYS, (client, secret, key, ctype)) if v])
2729
+ return self.get(), None, changed
2730
+
2731
+
2568
2732
  class Connections:
2569
2733
  """The lab's grants, held server-side; the page sees their state only."""
2570
2734
 
@@ -2584,7 +2748,8 @@ class Connections:
2584
2748
 
2585
2749
  @staticmethod
2586
2750
  def configured() -> bool:
2587
- return all((GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_API_KEY, GOOGLE_APP_ID))
2751
+ app = google_app()
2752
+ return bool(app and app.get("clientId") and app.get("clientSecret"))
2588
2753
 
2589
2754
  def _row(self):
2590
2755
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
@@ -2598,15 +2763,21 @@ class Connections:
2598
2763
 
2599
2764
  def start(self, origin: str):
2600
2765
  """The consent address the page sends the browser to."""
2766
+ app = google_app()
2601
2767
  if not self.configured():
2602
2768
  return None, (409, "Google is not configured for this lab")
2769
+ if app["clientType"] == "installed" and not loopback_origin(origin):
2770
+ return None, (409, "a Desktop app client signs in only on this machine: "
2771
+ "give this lab a Web application client in Configure…")
2603
2772
  state = secrets.token_urlsafe(24)
2604
2773
  now = time.time()
2605
2774
  with self.lock:
2606
2775
  self.states = {s: v for s, v in self.states.items() if v[0] > now}
2607
- self.states[state] = (now + GOOGLE_STATE_SECONDS, origin)
2776
+ # The app is kept with the state: the code Google sends back is
2777
+ # exchanged with the client that asked for it, whatever changes.
2778
+ self.states[state] = (now + GOOGLE_STATE_SECONDS, origin, app)
2608
2779
  return GOOGLE_CONSENT_URL + "?" + urllib.parse.urlencode({
2609
- "client_id": GOOGLE_CLIENT_ID, "redirect_uri": origin + GOOGLE_CALLBACK,
2780
+ "client_id": app["clientId"], "redirect_uri": origin + GOOGLE_CALLBACK,
2610
2781
  "response_type": "code", "scope": GOOGLE_SCOPE, "state": state,
2611
2782
  "access_type": "offline", "prompt": "consent", "include_granted_scopes": "true",
2612
2783
  }), None
@@ -2626,7 +2797,7 @@ class Connections:
2626
2797
  which never include the code or a token."""
2627
2798
  state = (query.get("state") or [""])[0]
2628
2799
  with self.lock:
2629
- lapses, origin = self.states.pop(state, (0, ""))
2800
+ lapses, origin, app = self.states.pop(state, (0, "", None))
2630
2801
  if lapses <= time.time():
2631
2802
  return "that sign-in had lapsed or was not this lab's; sign in again"
2632
2803
  if query.get("error"):
@@ -2636,7 +2807,7 @@ class Connections:
2636
2807
  return "Google sent no code back"
2637
2808
  try:
2638
2809
  got = self._call(GOOGLE_TOKEN_URL, {
2639
- "code": code, "client_id": GOOGLE_CLIENT_ID, "client_secret": GOOGLE_CLIENT_SECRET,
2810
+ "code": code, "client_id": app["clientId"], "client_secret": app["clientSecret"],
2640
2811
  "redirect_uri": origin + GOOGLE_CALLBACK, "grant_type": "authorization_code"})
2641
2812
  except (OSError, ValueError) as e:
2642
2813
  return f"the code could not be exchanged ({type(e).__name__})"
@@ -2654,6 +2825,12 @@ class Connections:
2654
2825
  time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
2655
2826
  return None
2656
2827
 
2828
+ def forget(self):
2829
+ """Drops the grant without a word to Google: the client it was made
2830
+ with is no longer this lab's, so the grant is no use to it."""
2831
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2832
+ db.execute("DELETE FROM connections WHERE id = 'google'")
2833
+
2657
2834
  def sign_out(self):
2658
2835
  """Forgets the grant, and asks Google to revoke it; a revoke that
2659
2836
  fails still forgets it here, which is what signing out means."""
@@ -2668,6 +2845,43 @@ class Connections:
2668
2845
  self.problem = None
2669
2846
 
2670
2847
 
2848
+ class MicrosoftApp:
2849
+ """The Microsoft app entered in Setup: a client ID and a tenant, neither
2850
+ secret, kept so a lab installed rather than deployed needs no environment
2851
+ variable to read a flow."""
2852
+
2853
+ KEYS = ("m365.clientId", "m365.tenant")
2854
+
2855
+ def __init__(self, store: Store):
2856
+ self.store = store
2857
+ with store.lock, closing(sqlite3.connect(store.path)) as db, db:
2858
+ db.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
2859
+
2860
+ def get(self):
2861
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2862
+ rows = dict(db.execute("SELECT key, value FROM settings WHERE key IN (?, ?)", self.KEYS))
2863
+ client = rows.get(self.KEYS[0])
2864
+ return {"clientId": client, "tenant": rows.get(self.KEYS[1]) or "organizations"} if client else None
2865
+
2866
+ def set(self, client_id, tenant):
2867
+ """Keeps them, or with no client ID forgets them: (kept, error)."""
2868
+ if not isinstance(client_id, str) or not isinstance(tenant, str):
2869
+ return None, (400, "a Microsoft app is { clientId, tenant }")
2870
+ client_id, tenant = client_id.strip(), tenant.strip() or "organizations"
2871
+ if tenant.lower() == "organizations":
2872
+ tenant = "organizations"
2873
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2874
+ if not client_id:
2875
+ db.execute("DELETE FROM settings WHERE key IN (?, ?)", self.KEYS)
2876
+ return None, None
2877
+ bad = microsoft_problem(client_id, tenant)
2878
+ if bad:
2879
+ return None, (400, bad)
2880
+ db.executemany("INSERT INTO settings VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2881
+ zip(self.KEYS, (client_id, tenant)))
2882
+ return self.get(), None
2883
+
2884
+
2671
2885
  class Plugins:
2672
2886
  """The installed plugins: their files on disk, their manifests in the store."""
2673
2887
 
@@ -3517,17 +3731,17 @@ def worker_destinations(run: dict):
3517
3731
  base = api_base(str(conn.get("url") or "")) or api_base(OLLAMA)
3518
3732
  why = allowed(base, "")
3519
3733
  if why:
3520
- return None, f"Setup profile {name}: {why}"
3734
+ return None, f"Target profile {name}: {why}"
3521
3735
  profile = next((p for p in stored if isinstance(p, dict) and p.get("id") == pid), None)
3522
3736
  if profile is None:
3523
- return None, f"Setup profile {name} not found"
3737
+ return None, f"Target profile {name} not found"
3524
3738
  key = str(profile.get("key") or "").strip()
3525
3739
  if key:
3526
3740
  if not header_safe(key):
3527
- return None, f"Setup profile {name} has a key that cannot go in a header"
3741
+ return None, f"Target profile {name} has a key that cannot go in a header"
3528
3742
  why = allowed(base, key)
3529
3743
  if why:
3530
- return None, f"Setup profile {name}: {why}"
3744
+ return None, f"Target profile {name}: {why}"
3531
3745
  env[key_var(pid)] = key
3532
3746
  return env, None
3533
3747
 
@@ -3546,12 +3760,17 @@ def worker_destinations(run: dict):
3546
3760
  # 7: chains are jobs: the field is `jobs` and each job's type is "job".
3547
3761
  # 8: a job is its steps; the content is job 1's Attach Content step.
3548
3762
  # 9: a test is Metrics; a Single Test or a Graded set is read converted.
3549
- PIPELINE_VERSION = 9
3763
+ # 10: a job's steps are its stages, and each scenario is a target whose own
3764
+ # step in each job is what it sends there (docs/pipeline-model.md §16).
3765
+ PIPELINE_VERSION = 10
3550
3766
  # What a stored run may be: the current version, and the ones evals-core.ts's
3551
- # upgradePipeline reads. A new submission is always the current one.
3552
- READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9)
3553
- SCENARIO_CAP = 4
3554
- RUN_FIELDS = ("version", "id", "name", "jobs", "scenarios", "tests", "profiles", "comment", "plugins")
3767
+ # upgradePipeline reads. A new submission is upgraded to the current one.
3768
+ READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
3769
+ TARGET_CAP = 4
3770
+ # Target steps whose words the Prompt library does not record as a use: they
3771
+ # ask no model (evals-core.ts's STEP_TYPES.echo).
3772
+ UNRECORDED_STEPS = {"echo"}
3773
+ RUN_FIELDS = ("version", "id", "name", "jobs", "targets", "tests", "profiles", "comment", "plugins")
3555
3774
 
3556
3775
 
3557
3776
  def content_of(doc):
@@ -3751,16 +3970,20 @@ def upgrade_run(run):
3751
3970
  return up, None
3752
3971
 
3753
3972
 
3754
- def answers_from_item(run, sc, cell):
3755
- """Whether job 1 of a scenario is answered from its item's own text --
3756
- a local type (Echo) over a Source or Text -- so its prompt may be blank:
3757
- evals-core.ts's rule, mirrored. Over Prompt only the prompt is the reply."""
3758
- content = content_of(run)
3759
- if not isinstance(content, dict) or content.get("type") not in ("source", "text"):
3760
- return False
3761
- ref = cell.get("profile") or sc.get("profile")
3762
- conn = (run.get("profiles") or {}).get(ref.get("id")) if isinstance(ref, dict) else None
3763
- return isinstance(conn, dict) and conn.get("type") in LOCAL_CONNECTIONS
3973
+ def cells_of(run):
3974
+ """Every target's step in every job of a run, as (i, target, k, step):
3975
+ version 10's targets and their steps, or an earlier version's scenarios
3976
+ and their cells -- what the Prompt library records a use from, whichever
3977
+ version a stored run is."""
3978
+ if not isinstance(run, dict):
3979
+ return
3980
+ targets = run.get("targets") if isinstance(run.get("targets"), list) else run.get("scenarios")
3981
+ for i, t in enumerate(targets if isinstance(targets, list) else []):
3982
+ if not isinstance(t, dict):
3983
+ continue
3984
+ steps = t.get("steps") if isinstance(t.get("steps"), list) else t.get("stages")
3985
+ for k, step in enumerate(steps if isinstance(steps, list) else []):
3986
+ yield i, t, k, step
3764
3987
 
3765
3988
 
3766
3989
  def run_problems(run):
@@ -3805,36 +4028,41 @@ def run_problems(run):
3805
4028
  bad.append(f"{at} has to be an object")
3806
4029
  continue
3807
4030
  connection_problems(pid, conn, at, bad)
3808
- scenarios = run.get("scenarios")
3809
- if not isinstance(scenarios, list) or not 1 <= len(scenarios) <= SCENARIO_CAP:
3810
- return bad + [f"a run needs between one and {SCENARIO_CAP} scenarios"]
3811
- ids = [sc.get("id") for sc in scenarios if isinstance(sc, dict)]
3812
- for i, sc in enumerate(scenarios):
3813
- at = f"scenario {i + 1}"
3814
- if not isinstance(sc, dict):
4031
+ targets = run.get("targets")
4032
+ if not isinstance(targets, list) or not 1 <= len(targets) <= TARGET_CAP:
4033
+ return bad + [f"a run needs between one and {TARGET_CAP} targets"]
4034
+ ids = [t.get("id") for t in targets if isinstance(t, dict)]
4035
+ for i, t in enumerate(targets):
4036
+ at = f"target {i + 1}"
4037
+ if not isinstance(t, dict):
3815
4038
  bad.append(f"{at} has to be an object")
3816
4039
  continue
3817
4040
  # The id the Prompt library records a use under (version 4).
3818
- if not isinstance(sc.get("id"), str) or not sc["id"].strip():
4041
+ if not isinstance(t.get("id"), str) or not t["id"].strip():
3819
4042
  bad.append(f"{at} has no id")
3820
- elif ids.count(sc["id"]) > 1:
3821
- bad.append(f"{at} has the id of another scenario")
3822
- stages = sc.get("stages")
3823
- if not isinstance(stages, list) or len(stages) != len(jobs):
3824
- bad.append(f"{at} has to have one prompt per job")
4043
+ elif ids.count(t["id"]) > 1:
4044
+ bad.append(f"{at} has the id of another target")
4045
+ steps = t.get("steps")
4046
+ if not isinstance(steps, list) or len(steps) != len(jobs):
4047
+ bad.append(f"{at} has to have one step per job")
3825
4048
  continue
3826
- refs = [sc.get("profile")] + [c.get("profile") for c in stages
3827
- if isinstance(c, dict) and c.get("profile") is not None]
3828
- for k, cell in enumerate(stages):
3829
- if not isinstance(cell, dict) or not isinstance(cell.get("prompt"), str) or (
3830
- not cell["prompt"].strip() and not (k == 0 and answers_from_item(run, sc, cell))):
4049
+ # A target with no profile is one whose steps ask none (Echo) or each
4050
+ # name their own; which steps need one is the core's to judge.
4051
+ refs = ([t["profile"]] if t.get("profile") is not None else []) + [
4052
+ st.get("profile") for st in steps if isinstance(st, dict) and st.get("profile") is not None]
4053
+ # Whether the words may be blank -- Echo answering from the item --
4054
+ # is the core's to judge, and the worker refuses the run in its words.
4055
+ for k, step in enumerate(steps):
4056
+ if not isinstance(step, dict) or not isinstance(step.get("type"), str):
4057
+ bad.append(f"{at}, job {k + 1} has to name what it sends")
4058
+ elif not isinstance(step.get("prompt"), str):
3831
4059
  bad.append(f"{at}, job {k + 1} has no prompt")
3832
- elif cell.get("from") is not None and not (
3833
- isinstance(cell["from"], dict) and isinstance(cell["from"].get("id"), str)):
4060
+ elif step.get("from") is not None and not (
4061
+ isinstance(step["from"], dict) and isinstance(step["from"].get("id"), str)):
3834
4062
  bad.append(f"{at}, job {k + 1} names the prompt it was picked from without an id")
3835
4063
  for ref in refs:
3836
4064
  if not isinstance(ref, dict) or ref.get("id") not in table:
3837
- bad.append(f"{at} names a Setup profile the run does not carry")
4065
+ bad.append(f"{at} names a Target profile the run does not carry")
3838
4066
  content = content_of(run)
3839
4067
  kind = content.get("type") if isinstance(content, dict) else None
3840
4068
  if kind == "source":
@@ -3873,10 +4101,12 @@ if DATA_DIR:
3873
4101
  PACKS = Packs(STORE)
3874
4102
  PLUGINS = Plugins(STORE)
3875
4103
  CONNECTIONS = Connections(STORE)
4104
+ MICROSOFT = MicrosoftApp(STORE)
4105
+ GOOGLE = GoogleApp(STORE)
3876
4106
  QUEUE = Queue(STORE)
3877
4107
  QUEUE.prompts = PROMPTS
3878
4108
  else:
3879
- STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = QUEUE = None
4109
+ STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
3880
4110
 
3881
4111
 
3882
4112
  class NoRedirects(urllib.request.HTTPRedirectHandler):
@@ -4030,9 +4260,10 @@ class Handler(BaseHTTPRequestHandler):
4030
4260
  # The lab's own limits, for the page to disable rather than
4031
4261
  # hard-code: how many scenarios a run may have.
4032
4262
  # 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})
4263
+ # The app may be entered in Setup unless the deployment names
4264
+ # its own, and only in a lab with a store to keep it in.
4265
+ return self._json(200, {"targetCap": TARGET_CAP, "microsoft": microsoft_config(),
4266
+ "microsoftEditable": MICROSOFT is not None and not M365_CLIENT_ID})
4036
4267
  if path == "/api/state":
4037
4268
  if STORE is None:
4038
4269
  return self._send(404, b"not found", "text/plain")
@@ -4087,6 +4318,10 @@ class Handler(BaseHTTPRequestHandler):
4087
4318
  if CONNECTIONS is None:
4088
4319
  return self._send(404, b"not found", "text/plain")
4089
4320
  return self._json(200, {"connections": CONNECTIONS.list()})
4321
+ if path == "/api/google":
4322
+ if GOOGLE is None:
4323
+ return self._send(404, b"not found", "text/plain")
4324
+ return self._json(200, {"google": google_public(google_app()), "editable": not GOOGLE_CLIENT_ID})
4090
4325
  if path == GOOGLE_CALLBACK:
4091
4326
  return self._google_callback()
4092
4327
  if path.startswith("/plugins/"):
@@ -4307,6 +4542,26 @@ class Handler(BaseHTTPRequestHandler):
4307
4542
  parts = self.path.split("?", 1)[0].split("/")
4308
4543
  if len(parts) == 4 and parts[:3] == ["", "api", "prompts"] and PROMPTS is not None:
4309
4544
  return self._prompts_put(parts[3])
4545
+ if parts == ["", "api", "google"] and GOOGLE is not None:
4546
+ if GOOGLE_CLIENT_ID:
4547
+ return self._json(409, {"error": "this lab's Google app is its deployment's (GOOGLE_CLIENT_ID)"})
4548
+ _, err, changed = GOOGLE.set(self._payload())
4549
+ if err:
4550
+ return self._json(err[0], {"error": err[1]})
4551
+ # A grant is its client's: one made with another client cannot be
4552
+ # refreshed with this one, so it goes rather than failing later.
4553
+ if changed and CONNECTIONS is not None:
4554
+ CONNECTIONS.forget()
4555
+ return self._json(200, {"google": google_public(google_app()), "editable": True})
4556
+ if parts == ["", "api", "microsoft"] and MICROSOFT is not None:
4557
+ if M365_CLIENT_ID:
4558
+ return self._json(409, {"error": "this lab's Microsoft app is its deployment's (M365_CLIENT_ID)"})
4559
+ payload = self._payload() or {}
4560
+ _, err = MICROSOFT.set(payload.get("clientId"), payload.get("tenant", ""))
4561
+ if err:
4562
+ code, message = err
4563
+ return self._json(code, {"error": message})
4564
+ return self._json(200, {"microsoft": microsoft_config()})
4310
4565
  if len(parts) == 5 and parts[:3] == ["", "api", "sources"] and parts[4] == "definition" \
4311
4566
  and SOURCES is not None:
4312
4567
  payload, err = self._dataset_payload(DEFINITION_CAP * 2, "a definition")
@@ -4857,7 +5112,7 @@ class Handler(BaseHTTPRequestHandler):
4857
5112
  # made in a lab without that sign-in.
4858
5113
  sign_in = (SOURCE_TYPES.get(kind) or {}).get("signIn")
4859
5114
  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)"
5115
+ return self._json(400, {"error": "this lab has no Microsoft app to sign in with (Setup › Connections)"
4861
5116
  if sign_in == "microsoft" else f"this lab has no {sign_in} sign-in"})
4862
5117
  source, err = SOURCES.create(payload.get("name"), kind, payload.get("config"))
4863
5118
  if err:
@@ -4982,6 +5237,10 @@ def main():
4982
5237
  if not (DEMO / "manifest.json").is_file():
4983
5238
  raise SystemExit(f"no {DEMO / 'manifest.json'} -- the demo pack sits beside server.py, "
4984
5239
  "and the image copies it there")
5240
+ # A published app the build wrote and that cannot be read is a broken
5241
+ # package, not one without the app: said at once, not at Sign in.
5242
+ if PUBLIC_GOOGLE_PROBLEM:
5243
+ raise SystemExit(PUBLIC_GOOGLE_PROBLEM)
4985
5244
  print(f"prompt-lab on {HOST}:{PORT} -> {OLLAMA} by default", flush=True)
4986
5245
  print(f"samples: {SAMPLES}", flush=True)
4987
5246
  print(f"page: {WEB_DIST}", flush=True)