evals-lab 0.13.0 → 0.15.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -2
  3. package/bin/run.js +4 -2
  4. package/lab/VERSION +1 -1
  5. package/lab/demo/CREDITS.md +14 -0
  6. package/lab/demo/datasets/demo-1.json +2 -2
  7. package/lab/demo/datasets/demo-2.json +2 -2
  8. package/lab/demo/datasets/documents-type.json +79 -0
  9. package/lab/demo/datasets/documents-valid.json +117 -0
  10. package/lab/demo/datasets/sentiment.json +2 -2
  11. package/lab/demo/datasets/weather-alerts.json +2 -2
  12. package/lab/demo/datasets/wordings.json +1 -1
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +17 -14
  15. package/lab/demo/pipelines/demo-2.json +17 -14
  16. package/lab/demo/pipelines/documents.json +101 -0
  17. package/lab/demo/pipelines/sentiment.json +4 -4
  18. package/lab/demo/pipelines/weather-alerts.json +9 -5
  19. package/lab/demo/pipelines/wordings.json +3 -3
  20. package/lab/demo/sources/Demo 1/source.json +1 -1
  21. package/lab/demo/sources/Demo 2/source.json +1 -1
  22. package/lab/demo/sources/documents/invoice.pdf +65 -0
  23. package/lab/demo/sources/documents/letter.pdf +63 -0
  24. package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
  25. package/lab/demo/sources/documents/receipt.pdf +62 -0
  26. package/lab/demo/sources/documents/report.pdf +59 -0
  27. package/lab/demo/sources/documents/source.json +3 -0
  28. package/lab/demo/sources/documents/truncated.pdf +19 -0
  29. package/lab/demo/sources/sentiment/source.json +1 -1
  30. package/lab/demo/sources/weather-alerts/source.json +1 -1
  31. package/lab/evals-core.mjs +408 -160
  32. package/lab/flows/record.mjs +5 -4
  33. package/lab/kinds/list.mjs +337 -182
  34. package/lab/run-evals.js +3 -1
  35. package/lab/server.py +661 -108
  36. package/lab/web/dist/assets/gallery-Cy8oycht.js +5 -0
  37. package/lab/web/dist/assets/gallery-eVJoaK-6.css +1 -0
  38. package/lab/web/dist/assets/main-B-LGmhPu.css +1 -0
  39. package/lab/web/dist/assets/main-wqNvXpWp.js +15 -0
  40. package/lab/web/dist/assets/tokens-CWVzmtid.css +1 -0
  41. package/lab/web/dist/assets/tokens-mCS6IZGh.js +64 -0
  42. package/lab/web/dist/gallery.html +4 -4
  43. package/lab/web/dist/index.html +4 -4
  44. package/package.json +1 -1
  45. package/lab/web/dist/assets/gallery-B_-TH0F-.css +0 -1
  46. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +0 -5
  47. package/lab/web/dist/assets/main-BSw-jyYD.css +0 -1
  48. package/lab/web/dist/assets/main-CrB8wPZn.js +0 -17
  49. package/lab/web/dist/assets/tokens-CrjDMCbz.js +0 -60
  50. package/lab/web/dist/assets/tokens-DDOONQon.css +0 -1
package/lab/server.py CHANGED
@@ -375,13 +375,13 @@ _CALL_UPLOADS = {".txt": "text/plain; charset=utf-8",
375
375
  ".csv": "text/csv; charset=utf-8",
376
376
  ".md": "text/markdown; charset=utf-8"}
377
377
  SOURCE_PROVIDERS = {
378
- "http-get": {"label": "HTTP GET", "noun": "call", "tier": "pull", "flag": None,
378
+ "http-get": {"label": "HTTP GET", "noun": "response", "tier": "pull", "flag": None,
379
379
  "uploads": dict(_CALL_UPLOADS)},
380
380
  # The API call provider (docs/dynamic-sources.md § 2): HTTP GET plus a verb,
381
381
  # headers and a body, keyed from a connection -- the same relay fetch, one
382
382
  # layer up. `produce` and `take` are attached below, beside http-get's.
383
383
  # `keyed`: it sends a connection's key, so a pack may not carry one (read_pack).
384
- "api": {"label": "API call", "noun": "call", "tier": "pull", "flag": None, "keyed": True,
384
+ "api": {"label": "API call", "noun": "response", "tier": "pull", "flag": None, "keyed": True,
385
385
  "uploads": dict(_CALL_UPLOADS), "longConfig": {"body": 8000}},
386
386
  # Supplied has no `produce`: the caller hands items in (the CLI's --items, or
387
387
  # an Upload… by hand), so the server fetches and executes nothing -- it only
@@ -668,7 +668,44 @@ def take_record(name, data):
668
668
  return json.dumps(kept, indent=2).encode("utf-8"), None
669
669
 
670
670
 
671
+ def describe_record(data):
672
+ """A kept record's first response, as the Calls table says it (#587):
673
+ one from a run was Recorded when production ran it; one written by hand
674
+ was Added, an uploaded one Uploaded, and an older Edited one Edited --
675
+ each with the reply's status, when it has one. None for bytes that are
676
+ not a record."""
677
+ try:
678
+ value = json.loads(data.decode("utf-8"))
679
+ except (UnicodeDecodeError, ValueError):
680
+ return None
681
+ if not isinstance(value, dict):
682
+ return None
683
+ origin = (value.get("origin") or {}).get("kind")
684
+ res = value.get("result") if isinstance(value.get("result"), dict) else {}
685
+ status = res.get("status") if isinstance(res.get("status"), int) else None
686
+ kind = {"run": "recorded", "hand": "added", "edited": "edited"}.get(origin, "uploaded")
687
+ return {"kind": kind, "status": status,
688
+ "at": value.get("at") if kind == "recorded" and isinstance(value.get("at"), str) else None}
689
+
690
+
691
+ def record_key(data):
692
+ """flows/record.ts's recordKey for a call from a run: its step, run and
693
+ iteration, kept by an edited record too, so Import runs never brings
694
+ production's call back beside the edit. None for a record from no run,
695
+ and for bytes that are not one."""
696
+ try:
697
+ value = json.loads(data.decode("utf-8"))
698
+ except (UnicodeDecodeError, ValueError):
699
+ return None
700
+ origin = value.get("origin") if isinstance(value, dict) else None
701
+ if not isinstance(origin, dict) or not origin.get("run"):
702
+ return None
703
+ return json.dumps({"step": value.get("step"), "run": origin["run"], "iteration": origin.get("iteration") or ""})
704
+
705
+
671
706
  WORKFLOW_PLATFORMS["power-automate"]["take"] = take_record
707
+ WORKFLOW_PLATFORMS["power-automate"]["describe"] = describe_record
708
+ WORKFLOW_PLATFORMS["power-automate"]["key"] = record_key
672
709
 
673
710
 
674
711
  # ---- A dynamic Source's Calls ------------------------------------------------
@@ -1859,6 +1896,9 @@ class Workspaces:
1859
1896
  "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1860
1897
  db.execute("DELETE FROM source_definitions WHERE source IN "
1861
1898
  "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1899
+ for table in ("source_calls", "source_responses"):
1900
+ db.execute(f"DELETE FROM {table} WHERE source IN "
1901
+ "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1862
1902
  db.execute("DELETE FROM sources WHERE workspace = ? AND system = 0", (ws,))
1863
1903
  db.execute("DELETE FROM eval_group_versions WHERE group_id IN "
1864
1904
  "(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
@@ -2046,6 +2086,17 @@ class Sources:
2046
2086
  "origin TEXT NOT NULL, at TEXT NOT NULL, "
2047
2087
  "provider TEXT, request TEXT, status INTEGER, error TEXT, file TEXT, "
2048
2088
  "PRIMARY KEY (source, name))")
2089
+ # An item's responses (#587): each fetch, import or edit of a
2090
+ # Call or a record kept with its bytes, so Restore can make an
2091
+ # earlier one current again. The file in source_files is the
2092
+ # current one's bytes, and what a run grades; these rows are
2093
+ # only read back by Restore. An item that has had one response
2094
+ # has no rows yet: its first is written when a second arrives.
2095
+ db.execute("CREATE TABLE IF NOT EXISTS source_responses ("
2096
+ "source TEXT NOT NULL, name TEXT NOT NULL, n INTEGER NOT NULL, "
2097
+ "at TEXT NOT NULL, kind TEXT NOT NULL, status INTEGER, body BLOB NOT NULL, "
2098
+ "current INTEGER NOT NULL DEFAULT 0, "
2099
+ "PRIMARY KEY (source, name, n))")
2049
2100
  db.execute("INSERT OR IGNORE INTO sources (id, name, system, bytes, created_at) "
2050
2101
  "VALUES (?, 'Sample library', 1, 0, ?)",
2051
2102
  (self.SYSTEM_ID, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
@@ -2256,10 +2307,11 @@ class Sources:
2256
2307
  if m.get("kind") == "source" and isinstance(m.get("source"), str):
2257
2308
  held.add(m["source"])
2258
2309
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2259
- for (sid,) in db.execute("SELECT DISTINCT source FROM source_definitions "
2260
- "WHERE source NOT IN (SELECT id FROM sources)").fetchall():
2261
- if sid not in held:
2262
- db.execute("DELETE FROM source_definitions WHERE source = ?", (sid,))
2310
+ for table in ("source_definitions", "source_calls", "source_responses"):
2311
+ for (sid,) in db.execute(f"SELECT DISTINCT source FROM {table} "
2312
+ "WHERE source NOT IN (SELECT id FROM sources)").fetchall():
2313
+ if sid not in held:
2314
+ db.execute(f"DELETE FROM {table} WHERE source = ?", (sid,))
2263
2315
 
2264
2316
  def type_of(self, sid):
2265
2317
  """A Source's enforcement entry -- its kind's, or its workflow
@@ -2446,6 +2498,16 @@ class Sources:
2446
2498
  if missing:
2447
2499
  return None, (404, f"{missing[0]!r} is not in that Source")
2448
2500
  total = sum(rows[n][0] for n in names)
2501
+ marks = ",".join("?" * len(names))
2502
+ # A Call's provenance row and an item's responses go to the trash
2503
+ # with its file, so Undo puts the item back as it was.
2504
+ calls = [list(r) for r in db.execute(
2505
+ "SELECT name, origin, at, provider, request, status, error, file FROM source_calls "
2506
+ f"WHERE source = ? AND file IN ({marks})", (sid, *names))]
2507
+ responses = [[r[0], r[1], r[2], r[3], r[4], base64.b64encode(r[5]).decode("ascii"), r[6]]
2508
+ for r in db.execute(
2509
+ "SELECT name, n, at, kind, status, body, current FROM source_responses "
2510
+ f"WHERE source = ? AND name IN ({marks})", (sid, *names))]
2449
2511
  with db:
2450
2512
  for name in names:
2451
2513
  db.execute("DELETE FROM source_files WHERE source = ? AND name = ?",
@@ -2454,6 +2516,8 @@ class Sources:
2454
2516
  # with it (a File Library has no such rows).
2455
2517
  db.execute("DELETE FROM source_calls WHERE source = ? AND file = ?",
2456
2518
  (sid, name))
2519
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ?",
2520
+ (sid, name))
2457
2521
  db.execute("UPDATE sources SET bytes = bytes - ? WHERE id = ?",
2458
2522
  (total, sid))
2459
2523
  token = secrets.token_hex(6)
@@ -2462,7 +2526,8 @@ class Sources:
2462
2526
  (entry / "thumbs").mkdir(parents=True)
2463
2527
  (entry / "manifest.json").write_text(json.dumps({
2464
2528
  "kind": "files", "source": sid, "at": time.time(),
2465
- "files": [{"name": n, "bytes": rows[n][0], "at": rows[n][1]} for n in names]}))
2529
+ "files": [{"name": n, "bytes": rows[n][0], "at": rows[n][1]} for n in names],
2530
+ "calls": calls, "responses": responses}))
2466
2531
  src = self.dir / sid
2467
2532
  for name in names:
2468
2533
  shutil.move(str(src / name), str(entry / "files" / name))
@@ -2527,6 +2592,12 @@ class Sources:
2527
2592
  if r["file"] and r["file"] in sizes:
2528
2593
  call["bytes"] = sizes[r["file"]]
2529
2594
  out.append(call)
2595
+ # When and how its current response came: the Updated column (#587).
2596
+ held = self.responses(sid) or {}
2597
+ for call in out:
2598
+ now = next((x for x in held.get(call["name"], []) if x["current"]), None)
2599
+ if now is not None:
2600
+ call["updated"] = {"kind": now["kind"], "at": now["at"]}
2530
2601
  return out
2531
2602
 
2532
2603
  def _record_call(self, sid, name, origin, provider, request, status, error, file):
@@ -2540,11 +2611,18 @@ class Sources:
2540
2611
  provider, json.dumps(request) if request else None, status, error, file))
2541
2612
 
2542
2613
  def snapshot(self, sid):
2543
- """Produce one Call now and keep it (Snapshot now): the server fetches
2544
- through the provider, the `take` hook refuses a token and redacts, and
2545
- the content is kept as a text file graded as an item. A failed fetch is
2546
- kept as a Call with its error, not dropped. Returns (call, None), or
2547
- (None, error)."""
2614
+ """Get response (#587): produce the Source's Call through the provider,
2615
+ the `take` hook refusing a token and redacting, and keep it as a
2616
+ version of the response its name says (#596). A response already held
2617
+ gains the version and keeps its row, whatever was done to it -- an edit
2618
+ is a version too, so nothing forks -- unless the bytes are the current
2619
+ version's, which adds nothing. A name not held is a new response. A
2620
+ failed fetch leaves a response's current version as it is; one with
2621
+ no response yet is kept as a Call with its error, not dropped.
2622
+ Returns ({name, outcome, ...}, None) -- outcome `fetched`,
2623
+ `unchanged`, `added` or `failed`, and for `fetched` the response it
2624
+ replaced (`was`) and the one it added (`n`), what Undo hands back --
2625
+ or (None, error)."""
2548
2626
  got, err = self._dynamic_entry(sid)
2549
2627
  if err:
2550
2628
  return None, err
@@ -2559,46 +2637,236 @@ class Sources:
2559
2637
  request = result.get("request")
2560
2638
  status = result.get("status")
2561
2639
  content = result.get("content")
2562
- if content is None:
2563
- # A failed production: kept as a Call with its error, no file.
2564
- name = self._snapshot_name(sid, dynamic_item_name(cfg, (request or {}).get("url", "")
2565
- or secrets.token_hex(6)))
2566
- self._record_call(sid, name, "snapshot", provider, request, status, result.get("error"), None)
2567
- return {"name": name, "origin": "snapshot", "at": None, "provider": provider,
2568
- "request": request, "status": status, "error": result.get("error")}, None
2569
- name = self._snapshot_name(sid, dynamic_item_name(cfg, content))
2570
- kept, take_err = entry["take"](name, content)
2571
- if take_err:
2572
- code, message = take_err
2573
- self._record_call(sid, name, "snapshot", provider, request, status, message, None)
2574
- return {"name": name, "origin": "snapshot", "at": None, "provider": provider,
2575
- "request": request, "status": status, "error": message}, None
2576
- # The name is stable (the URL's), so a re-snapshot overwrites the item
2577
- # rather than adding a second: remove the old file first, then add. An
2578
- # Edited Call is never the one overwritten (_snapshot_name).
2579
- self.remove_files(sid, [name])
2580
- stored, add_err = self.add_file(sid, name, kept)
2581
- if add_err:
2582
- return None, add_err
2583
- self._record_call(sid, stored, "snapshot", provider, request, status, None, stored)
2584
- return {"name": stored, "origin": "snapshot", "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
2585
- "provider": provider, "request": request, "status": status, "bytes": len(kept)}, None
2586
-
2587
- def _snapshot_name(self, sid, name):
2588
- """The name a snapshot is kept under: [name], unless an Edited Call
2589
- holds it -- a snapshot never overwrites an edit -- and then the first
2590
- of `<stem>-2`, `<stem>-3`... that none does. One that is not Edited is
2591
- overwritten, as the stable name always was."""
2640
+ failure = result.get("error")
2641
+ name = kept = None
2642
+ if content is not None:
2643
+ name = dynamic_item_name(cfg, content)
2644
+ kept, take_err = entry["take"](name, content)
2645
+ if take_err:
2646
+ failure = take_err[1]
2647
+ else:
2648
+ name = dynamic_item_name(cfg, (request or {}).get("url", "") or secrets.token_hex(6))
2649
+ path = self.file_path(sid, name)
2650
+ held = path is not None and path.is_file()
2651
+ if failure is not None:
2652
+ if not held:
2653
+ self._record_call(sid, name, "snapshot", provider, request, status, failure, None)
2654
+ return {"name": name, "outcome": "failed", "provider": provider,
2655
+ "request": request, "status": status, "error": failure}, None
2656
+ if not held:
2657
+ stored, add_err = self.add_file(sid, name, kept)
2658
+ if add_err:
2659
+ return None, add_err
2660
+ self._record_call(sid, stored, "snapshot", provider, request, status, None, stored)
2661
+ return {"name": stored, "outcome": "added", "provider": provider, "request": request,
2662
+ "status": status, "bytes": len(kept)}, None
2663
+ if path.read_bytes() == kept:
2664
+ # Status is the newest fetch's, though Updated stays when the
2665
+ # current response came.
2666
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2667
+ db.execute("UPDATE source_calls SET status = ?, error = NULL WHERE source = ? AND name = ?",
2668
+ (status, sid, name))
2669
+ return {"name": name, "outcome": "unchanged", "status": status}, None
2670
+ added, err = self._respond(sid, name, kept, "fetched", status)
2671
+ if err:
2672
+ return None, err
2673
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2674
+ db.execute("UPDATE source_calls SET request = ?, provider = ?, status = ?, error = NULL, file = ? "
2675
+ "WHERE source = ? AND name = ?",
2676
+ (json.dumps(request) if request else None, provider, status, name, sid, name))
2677
+ return {"name": name, "outcome": "fetched", "provider": provider, "request": request,
2678
+ "status": status, "bytes": len(kept), **added}, None
2679
+
2680
+ # ---- an item's responses (#587) -----------------------------------------
2681
+ #
2682
+ # A Call or a record keeps its responses: each fetch, import or edit is
2683
+ # one, kept with its bytes, and the newest -- or the one Restore picks --
2684
+ # is current: its bytes are the file a run grades. Nothing forks; a copy
2685
+ # (Clone) is a new item with a response of its own.
2686
+
2687
+ # How many responses an item keeps: the oldest that is neither current nor
2688
+ # pinned by a pipeline (#596) goes.
2689
+ RESPONSES_KEPT = 20
2690
+ # The kind of response a Call's provenance row says its file was.
2691
+ ORIGIN_KINDS = {"snapshot": "fetched", "run": "fetched", "edited": "edited",
2692
+ "upload": "uploaded", "hand": "added", "copy": "copied"}
2693
+ RESPONSE_KINDS = ("fetched", "recorded", "uploaded", "added", "edited", "copied")
2694
+
2695
+ def _described(self, sid, name, data):
2696
+ """What an item's bytes were before it kept responses: from its Call's
2697
+ provenance row, else from its kind's `describe` hook (a record says
2698
+ whether production recorded it), else an upload, as of the file."""
2592
2699
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2593
- edited = {r[0] for r in db.execute(
2594
- "SELECT name FROM source_calls WHERE source = ? AND origin = 'edited'", (sid,))}
2595
- if name not in edited:
2596
- return name
2597
- stem, ext = os.path.splitext(name)
2598
- n = 2
2599
- while f"{stem}-{n}{ext}" in edited:
2600
- n += 1
2601
- return f"{stem}-{n}{ext}"
2700
+ row = db.execute("SELECT origin, at, status FROM source_calls WHERE source = ? AND name = ?",
2701
+ (sid, name)).fetchone()
2702
+ f = db.execute("SELECT at FROM source_files WHERE source = ? AND name = ?",
2703
+ (sid, name)).fetchone()
2704
+ if row is not None:
2705
+ return {"kind": self.ORIGIN_KINDS.get(row[0], "uploaded"), "at": row[1], "status": row[2]}
2706
+ describe = (self.type_of(sid) or {}).get("describe")
2707
+ got = describe(data) if describe else None
2708
+ out = {"kind": "uploaded", "at": f[0] if f else None, "status": None}
2709
+ if isinstance(got, dict):
2710
+ out.update({k: v for k, v in got.items() if v is not None})
2711
+ return out
2712
+
2713
+ def _respond(self, sid, name, data, kind, status):
2714
+ """[data] as [name]'s new current response: the file rewritten in
2715
+ place, as an edit always was, and the row added. An item with no rows
2716
+ yet first gains one for the bytes it held. Returns ({n, was}, None),
2717
+ or (None, error)."""
2718
+ path = self.file_path(sid, name)
2719
+ if path is None or not path.is_file():
2720
+ return None, (404, f"{name} is not in that Source")
2721
+ old = path.read_bytes()
2722
+ first = None
2723
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2724
+ seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
2725
+ (sid, name)).fetchone() is not None
2726
+ if not seeded:
2727
+ first = self._described(sid, name, old)
2728
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2729
+ folder = self.dir / sid
2730
+ with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2731
+ f.write(data)
2732
+ tmp = f.name
2733
+ try:
2734
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2735
+ total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
2736
+ if total + len(data) - len(old) > MAX_SOURCE:
2737
+ raise _Refused((413, "that would take the source past its cap"))
2738
+ n, was = self._response_rows(db, sid, name, old, first, data, kind, status, now)
2739
+ db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
2740
+ (len(data), sid, name))
2741
+ db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2742
+ except _Refused as no:
2743
+ os.unlink(tmp)
2744
+ return None, no.err
2745
+ os.replace(tmp, folder / name)
2746
+ return {"n": n, "was": was}, None
2747
+
2748
+ def _response_rows(self, db, sid, name, old, first, data, kind, status, now):
2749
+ """The rows behind _respond and an edit, inside the caller's
2750
+ transaction: the bytes [name] held as its first response when it had
2751
+ none ([first] describes them), [data] as the new current one, the
2752
+ oldest past RESPONSES_KEPT dropped -- never one a pipeline pins
2753
+ (#596), which does not count against the rest. Returns (n, was)."""
2754
+ if first is not None:
2755
+ db.execute("INSERT OR IGNORE INTO source_responses (source, name, n, at, kind, status, "
2756
+ "body, current) VALUES (?, ?, 1, ?, ?, ?, ?, 1)",
2757
+ (sid, name, first["at"] or now, first["kind"], first["status"], old))
2758
+ was = db.execute("SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 1",
2759
+ (sid, name)).fetchone()
2760
+ n = db.execute("SELECT COALESCE(MAX(n), 0) + 1 FROM source_responses WHERE source = ? "
2761
+ "AND name = ?", (sid, name)).fetchone()[0]
2762
+ db.execute("UPDATE source_responses SET current = 0 WHERE source = ? AND name = ?", (sid, name))
2763
+ db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
2764
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1)", (sid, name, n, now, kind, status, data))
2765
+ pinned = response_pins(db, sid, name)
2766
+ loose = [r for (r,) in db.execute(
2767
+ "SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 0 "
2768
+ "ORDER BY n DESC", (sid, name)).fetchall() if r not in pinned]
2769
+ for gone in loose[self.RESPONSES_KEPT - 1:]:
2770
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?", (sid, name, gone))
2771
+ db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?", (status, sid, name))
2772
+ return n, (was[0] if was else None)
2773
+
2774
+ def pinned_body(self, sid, name, n):
2775
+ """The bytes of [name]'s response [n] a run's pin names (#596), or
2776
+ None when the response is gone. `n` 0 is an item that kept one
2777
+ response and no rows: its file, or once it gained rows, the first,
2778
+ which holds those bytes."""
2779
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2780
+ row = db.execute("SELECT body FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2781
+ (sid, name, n or 1)).fetchone()
2782
+ seeded = row is not None or db.execute(
2783
+ "SELECT 1 FROM source_responses WHERE source = ? AND name = ?", (sid, name)).fetchone() is not None
2784
+ if row is not None:
2785
+ return bytes(row[0])
2786
+ if n == 0 and not seeded:
2787
+ path = self.file_path(sid, name)
2788
+ return path.read_bytes() if path is not None and path.is_file() else None
2789
+ return None
2790
+
2791
+ def responses(self, sid):
2792
+ """Every item's responses, newest first, by name: {n, at, kind,
2793
+ status, current} -- no bytes. An item that has kept one response has
2794
+ no rows, and reads as that one, `n` 0, described as it was made. None
2795
+ for no such Source."""
2796
+ if self.get(sid) is None:
2797
+ return None
2798
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2799
+ names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
2800
+ rows = db.execute("SELECT name, n, at, kind, status, current FROM source_responses "
2801
+ "WHERE source = ? ORDER BY n DESC", (sid,)).fetchall()
2802
+ out = {}
2803
+ for name, n, at, kind, status, current in rows:
2804
+ out.setdefault(name, []).append({"n": n, "at": at, "kind": kind, "status": status,
2805
+ "current": bool(current)})
2806
+ for name in names:
2807
+ if name not in out:
2808
+ path = self.file_path(sid, name)
2809
+ data = path.read_bytes() if path is not None and path.is_file() else b""
2810
+ out[name] = [{"n": 0, **self._described(sid, name, data), "current": True}]
2811
+ return out
2812
+
2813
+ def restore_response(self, sid, name, n, drop=None):
2814
+ """Makes [name]'s response [n] current: its bytes the file again, the
2815
+ response it kept, not a new one. [drop] removes a response outright as
2816
+ well -- Undo of a fetch or an edit takes back the one it added. Returns
2817
+ ({name, n}, None), or (None, error)."""
2818
+ entry, err = self._calls_entry(sid)
2819
+ if err:
2820
+ return None, err
2821
+ path = self.file_path(sid, name)
2822
+ if path is None or not path.is_file():
2823
+ return None, (404, f"{name} is not in that Source")
2824
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2825
+ row = db.execute("SELECT body, status FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2826
+ (sid, name, n)).fetchone()
2827
+ if row is None:
2828
+ return None, (404, f"{name} has no such response")
2829
+ data, status = bytes(row[0]), row[1]
2830
+ old = path.read_bytes()
2831
+ folder = self.dir / sid
2832
+ with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2833
+ f.write(data)
2834
+ tmp = f.name
2835
+ try:
2836
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2837
+ total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
2838
+ if total + len(data) - len(old) > MAX_SOURCE:
2839
+ raise _Refused((413, "that would take the source past its cap"))
2840
+ db.execute("UPDATE source_responses SET current = (n = ?) WHERE source = ? AND name = ?",
2841
+ (n, sid, name))
2842
+ if drop is not None and drop != n:
2843
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2844
+ (sid, name, drop))
2845
+ db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
2846
+ (len(data), sid, name))
2847
+ db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2848
+ db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?",
2849
+ (status, sid, name))
2850
+ except _Refused as no:
2851
+ os.unlink(tmp)
2852
+ return None, no.err
2853
+ os.replace(tmp, folder / name)
2854
+ return {"name": name, "n": n}, None
2855
+
2856
+ def key_held(self, sid, key, value):
2857
+ """The name of the file of [sid] whose [key] (its kind's `key` hook)
2858
+ is [value], or None: a record from a run already held is not kept a
2859
+ second time, however it was edited since."""
2860
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2861
+ names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
2862
+ for name in names:
2863
+ path = self.file_path(sid, name)
2864
+ try:
2865
+ if path is not None and key(path.read_bytes()) == value:
2866
+ return name
2867
+ except OSError:
2868
+ continue
2869
+ return None
2602
2870
 
2603
2871
  # ---- a Call opened in the Call dialog: edited, renamed, cloned (#438) ---
2604
2872
  #
@@ -2608,7 +2876,7 @@ class Sources:
2608
2876
  # the rest redacted. A File Library has no hook and no Calls.
2609
2877
 
2610
2878
  # The origins a dynamic Call's provenance row may be put back to (Undo).
2611
- CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited")
2879
+ CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited", "copy")
2612
2880
 
2613
2881
  def _calls_entry(self, sid):
2614
2882
  """The enforcement entry of a user Source whose files are Calls, or
@@ -2636,11 +2904,13 @@ class Sources:
2636
2904
  the Source's `take` hook as an upload does. A rename keeps the
2637
2905
  extension and refuses a name in use, or a Call that a dataset's cases
2638
2906
  name ([named_by] answers which datasets do), since a case finds its
2639
- item by name. A dynamic Call's origin becomes `edited` when its text
2640
- changed -- a rename alone is no edit -- unless [origin] puts back the
2641
- one it had (Undo). Nothing is written unless all of it passes.
2642
- Returns ({name, origin, bytes}, None), or (None, (code, sentence)) --
2643
- (code, sentence, "name") when the Name field is what is wrong."""
2907
+ item by name. Changed text is a new response, `edited` -- a rename
2908
+ alone is no edit -- and the item's responses follow a rename. [origin]
2909
+ puts back a dynamic Call's provenance (an older page's Undo). Nothing
2910
+ is written unless all of it passes. Returns ({name, origin, bytes} and,
2911
+ for an edit, the response it added `n` and the one it replaced `was`,
2912
+ None), or (None, (code, sentence)) -- (code, sentence, "name") when
2913
+ the Name field is what is wrong."""
2644
2914
  entry, err = self._calls_entry(sid)
2645
2915
  if err:
2646
2916
  return None, err
@@ -2677,12 +2947,26 @@ class Sources:
2677
2947
  if take_err:
2678
2948
  return None, take_err
2679
2949
  changed = data != old
2950
+ first = status = None
2951
+ if changed:
2952
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2953
+ seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
2954
+ (sid, name)).fetchone() is not None
2955
+ held = db.execute("SELECT status FROM source_calls WHERE source = ? AND name = ?",
2956
+ (sid, name)).fetchone()
2957
+ if not seeded:
2958
+ first = self._described(sid, name, old)
2959
+ # An edited reply says its own status (a record's); an edited Call
2960
+ # keeps the status of the fetch it was.
2961
+ described = entry["describe"](data) if entry.get("describe") else None
2962
+ status = (described or {}).get("status") if described is not None else (held[0] if held else None)
2680
2963
  folder = self.dir / sid
2681
2964
  tmp = None
2682
2965
  if changed or new != name:
2683
2966
  with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2684
2967
  f.write(data)
2685
2968
  tmp = f.name
2969
+ added = {}
2686
2970
  try:
2687
2971
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2688
2972
  if new != name and new in self._names_held(db, sid):
@@ -2693,13 +2977,18 @@ class Sources:
2693
2977
  db.execute("UPDATE source_files SET name = ?, bytes = ? WHERE source = ? AND name = ?",
2694
2978
  (new, len(data), sid, name))
2695
2979
  db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2980
+ db.execute("UPDATE source_responses SET name = ? WHERE source = ? AND name = ?", (new, sid, name))
2696
2981
  row = db.execute("SELECT origin FROM source_calls WHERE source = ? AND name = ?",
2697
2982
  (sid, name)).fetchone()
2698
2983
  now = None
2699
2984
  if row is not None:
2700
- now = origin or ("edited" if changed else row[0])
2985
+ now = origin or row[0]
2701
2986
  db.execute("UPDATE source_calls SET name = ?, file = ?, origin = ? "
2702
2987
  "WHERE source = ? AND name = ?", (new, new, now, sid, name))
2988
+ if changed:
2989
+ n, was = self._response_rows(db, sid, new, old, first, data, "edited", status,
2990
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
2991
+ added = {"n": n, "was": was}
2703
2992
  except _Refused as no:
2704
2993
  if tmp:
2705
2994
  os.unlink(tmp)
@@ -2708,13 +2997,14 @@ class Sources:
2708
2997
  os.replace(tmp, folder / new)
2709
2998
  if new != name:
2710
2999
  (folder / name).unlink(missing_ok=True)
2711
- return {"name": new, "origin": now, "bytes": len(data)}, None
3000
+ return {"name": new, "origin": now, "bytes": len(data), **added}, None
2712
3001
 
2713
3002
  def clone_call(self, sid, name):
2714
- """A copy of one Call under `<stem>-copy` (then `-copy-2`, ...), its
2715
- file and, for a dynamic Call, its provenance -- origin, request,
2716
- status and when it was produced -- as they are. Returns ({name}, None),
2717
- or (None, error)."""
3003
+ """A copy of one Call under `<stem>-copy` (then `-copy-2`, ...): its
3004
+ current bytes as the copy's one response, `copied`, and for a dynamic
3005
+ Call its request and provider, its origin `copy` -- so a fetch never
3006
+ reaches it and a live run keeps it as saved (#587). Returns ({name},
3007
+ None), or (None, error)."""
2718
3008
  entry, err = self._calls_entry(sid)
2719
3009
  if err:
2720
3010
  return None, err
@@ -2731,13 +3021,18 @@ class Sources:
2731
3021
  if copy not in held:
2732
3022
  break
2733
3023
  n += 1
2734
- stored, err = self.add_file(sid, copy, path.read_bytes())
3024
+ data = path.read_bytes()
3025
+ stored, err = self.add_file(sid, copy, data)
2735
3026
  if err:
2736
3027
  return None, err
3028
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2737
3029
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2738
3030
  db.execute("INSERT INTO source_calls (source, name, origin, at, provider, request, status, error, file) "
2739
- "SELECT source, ?, origin, at, provider, request, status, error, ? "
2740
- "FROM source_calls WHERE source = ? AND name = ?", (stored, stored, sid, name))
3031
+ "SELECT source, ?, 'copy', ?, provider, request, status, error, ? "
3032
+ "FROM source_calls WHERE source = ? AND name = ?", (stored, now, stored, sid, name))
3033
+ # A copy starts its own responses: a fetch never reaches it.
3034
+ db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
3035
+ "VALUES (?, ?, 1, ?, 'copied', NULL, ?, 1)", (sid, stored, now, data))
2741
3036
  return {"name": stored}, None
2742
3037
 
2743
3038
  def remove_calls(self, sid, names):
@@ -2833,6 +3128,14 @@ class Sources:
2833
3128
  "VALUES (?, ?, ?, ?)",
2834
3129
  (sid, f["name"], f.get("bytes", 0), f.get("at", "")))
2835
3130
  db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (total, sid))
3131
+ # An entry from before calls and responses were trashed has neither.
3132
+ for c in m.get("calls") or []:
3133
+ db.execute("INSERT OR REPLACE INTO source_calls (source, name, origin, at, provider, "
3134
+ "request, status, error, file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (sid, *c))
3135
+ for r in m.get("responses") or []:
3136
+ db.execute("INSERT OR REPLACE INTO source_responses (source, name, n, at, kind, status, "
3137
+ "body, current) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
3138
+ (sid, r[0], r[1], r[2], r[3], r[4], base64.b64decode(r[5]), r[6]))
2836
3139
  th = self.dir / sid / ".thumbs"
2837
3140
  th.mkdir(parents=True, exist_ok=True)
2838
3141
  for f in files:
@@ -3402,23 +3705,89 @@ def upgrade_body(body):
3402
3705
 
3403
3706
 
3404
3707
  # The filter-set body's version: evals-core.ts's FILTER_SET_VERSION. Version 3
3405
- # (#432) drops the `stage` version 2 (#405) gave it: a set belongs to no stage,
3406
- # and each filter's type decides where it runs, so a set linked before and after
3407
- # Read as runs its share in each. Version 1 named no stage either. A filter set
3408
- # is a Library row the lab stores and versions like an eval group (#336,
3409
- # FilterSets below); this and its twin are the body it keeps, held to
3410
- # evals-core.ts by proxy-check.py.
3411
- FILTER_SET_BODY_VERSION = 3
3708
+ # (#432) dropped the `stage` version 2 (#405) gave it. Version 4 (#615) has
3709
+ # seven verbs that run wherever they sit, around a Response Format a set may
3710
+ # straddle (its `format`). A filter set is a Library row the lab stores and
3711
+ # versions like an eval group (#336, FilterSets below); this and its twin are
3712
+ # the body it keeps, held to evals-core.ts by proxy-check.py.
3713
+ FILTER_SET_BODY_VERSION = 4
3714
+ # A version-3 filter's types that read the reply's text; the rest read items.
3715
+ V3_TEXT_TYPES = ("rewrite-text", "reject-text")
3716
+
3717
+
3718
+ def filter_v4(f):
3719
+ """A version-3 filter as the version-4 verb that reads a reply exactly as
3720
+ it did: kinds/list.ts's filterV4, in Python. A filter of no version-3
3721
+ type comes back as it was. Pure."""
3722
+ if not isinstance(f, dict):
3723
+ return f
3724
+ op = f.get("op") if isinstance(f.get("op"), dict) else {}
3725
+
3726
+ def pick(**keys):
3727
+ # A setting the old op lacked stays absent, as JavaScript's undefined
3728
+ # does in JSON; one it held as null stays null.
3729
+ return {k: op[v] for k, v in keys.items() if v in op}
3730
+
3731
+ def as_(kind, new_op=None):
3732
+ out = {k: v for k, v in f.items() if k != "op"}
3733
+ out["type"] = kind
3734
+ if new_op is not None:
3735
+ out["op"] = new_op
3736
+ if f.get("type") in V3_TEXT_TYPES and isinstance(f.get("tests"), list):
3737
+ out["tests"] = [{**t, "separator": "none"} if isinstance(t, dict) else t for t in f["tests"]]
3738
+ return out
3739
+
3740
+ replacement = op.get("replacement")
3741
+ replacement = "" if replacement is None else replacement
3742
+ t = f.get("type")
3743
+ if t == "rewrite-text":
3744
+ k = op.get("kind")
3745
+ if k in ("thinking", "preamble", "fences"):
3746
+ return as_("remove", {"kind": k})
3747
+ if k == "trim":
3748
+ return as_("trim", {"bullets": False})
3749
+ if k == "literal":
3750
+ return as_("replace", {"kind": "text", **pick(find="find"), "replacement": replacement})
3751
+ if k == "replace":
3752
+ return as_("replace", {"kind": "pattern", **pick(pattern="pattern"), "replacement": replacement})
3753
+ if k == "case":
3754
+ return as_("change-case", pick(mode="mode"))
3755
+ if k == "first":
3756
+ return as_("keep-first", pick(n="n", unit="unit"))
3757
+ if k == "extract":
3758
+ return as_("extract", pick(pattern="pattern"))
3759
+ return f
3760
+ if t in ("reject-text", "reject-reply"):
3761
+ return as_("reject")
3762
+ if t == "drop-item":
3763
+ return as_("remove", {"kind": "items"})
3764
+ if t == "rewrite-item":
3765
+ return as_("change-case", pick(mode="mode")) if op.get("kind") == "case" else as_("trim", {"bullets": True})
3766
+ if t == "keep-first":
3767
+ return as_("keep-first", {**pick(n="n"), "unit": "items"})
3768
+ return f
3412
3769
 
3413
3770
 
3414
3771
  def upgrade_filter_set_body(body):
3415
- """An earlier filter-set body as today's (version 3): evals-core.ts's
3416
- upgradeFilterSetBody, in Python. A version-1 or -2 body loses any `stage`
3417
- and takes this version; a version-3 body, and anything that is not a body,
3418
- come back as they are. Pure."""
3419
- if isinstance(body, dict) and body.get("version") in (1, 2):
3420
- return {**{k: v for k, v in body.items() if k != "stage"}, "version": FILTER_SET_BODY_VERSION}
3421
- return body
3772
+ """An earlier filter-set body as today's (version 4): evals-core.ts's
3773
+ upgradeFilterSetBody, in Python. A version-1 to -3 body loses any `stage`,
3774
+ each filter becomes its verb (filter_v4), and one that held both text and
3775
+ item filters puts the text ones first, then its `format` marker, then the
3776
+ item ones. A version-4 body, and anything that is not a body, come back as
3777
+ they are. Pure."""
3778
+ if not isinstance(body, dict) or body.get("version") not in (1, 2, 3):
3779
+ return body
3780
+ rest = {k: v for k, v in body.items() if k != "stage"}
3781
+ filters = rest.get("filters")
3782
+ if not isinstance(filters, list):
3783
+ return {**rest, "version": FILTER_SET_BODY_VERSION}
3784
+ is_text = [isinstance(f, dict) and f.get("type") in V3_TEXT_TYPES for f in filters]
3785
+ text = [f for f, x in zip(filters, is_text) if x]
3786
+ items = [f for f, x in zip(filters, is_text) if not x]
3787
+ out = {**rest, "version": FILTER_SET_BODY_VERSION, "filters": [filter_v4(f) for f in text + items]}
3788
+ if text and items:
3789
+ out["format"] = len(text)
3790
+ return out
3422
3791
 
3423
3792
 
3424
3793
  def body_prompt(body):
@@ -3498,6 +3867,26 @@ def pins_in(workflows) -> set:
3498
3867
  return out
3499
3868
 
3500
3869
 
3870
+ def response_pins(db, sid, name) -> set:
3871
+ """Every response `n` of Source [sid]'s item [name] a stored pipeline pins
3872
+ (#596), across every workspace's promptlab.workflows document, read in
3873
+ the caller's transaction. A pin of `n` 0 -- an item before it kept rows --
3874
+ pins the first row, which holds those bytes."""
3875
+ out = set()
3876
+ for (body,) in db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows'"):
3877
+ try:
3878
+ listed = (json.loads(body) or {}).get("list") if body else None
3879
+ except (ValueError, TypeError, AttributeError):
3880
+ continue
3881
+ for w in listed if isinstance(listed, list) else []:
3882
+ c = content_of(w.get("work")) if isinstance(w, dict) else None
3883
+ pin = c.get("pin") if isinstance(c, dict) else None
3884
+ if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
3885
+ and pin.get("name") == name and type(pin.get("n")) is int):
3886
+ out.add(pin["n"] or 1)
3887
+ return out
3888
+
3889
+
3501
3890
  def group_key(ref) -> str:
3502
3891
  """The key a run keeps a group's body under: `<id>@<n>`, or the id alone
3503
3892
  for a reference from before the lab numbered versions."""
@@ -3544,6 +3933,8 @@ def free_name(base: str, taken: set, op: str) -> str:
3544
3933
  # are the body's; the per-filter shape is the core's to judge, as a case's
3545
3934
  # metrics are (dataset_problem). The suffixing is the generic one.
3546
3935
  FILTER_SET_FIELDS = ("version", "filters")
3936
+ # A body may also say where a job's Response Format reads among its filters.
3937
+ FILTER_SET_OPTIONAL = ("format",)
3547
3938
  FILTER_SET_NAME_MAX = 80
3548
3939
  unique_filter_set_name = unique_dataset_name
3549
3940
 
@@ -3554,7 +3945,7 @@ def filter_set_problem(body) -> str:
3554
3945
  if not isinstance(body, dict):
3555
3946
  return "a filter set's body is a JSON object"
3556
3947
  for k in body:
3557
- if k not in FILTER_SET_FIELDS:
3948
+ if k not in FILTER_SET_FIELDS + FILTER_SET_OPTIONAL:
3558
3949
  return f"a filter set's body has \"{k}\", which is not a filter-set field"
3559
3950
  for k in FILTER_SET_FIELDS:
3560
3951
  if k not in body:
@@ -3563,6 +3954,9 @@ def filter_set_problem(body) -> str:
3563
3954
  return f"a filter set's body is version {FILTER_SET_BODY_VERSION}"
3564
3955
  if not isinstance(body["filters"], list) or not all(isinstance(f, dict) for f in body["filters"]):
3565
3956
  return "filters has to be a list of filters"
3957
+ at = body.get("format")
3958
+ if at is not None and (type(at) is not int or not 0 <= at <= len(body["filters"])):
3959
+ return "Response Format has to sit among its filters"
3566
3960
  return ""
3567
3961
 
3568
3962
 
@@ -3826,9 +4220,13 @@ class Prompts:
3826
4220
  prompt -- the version reading its words, or a new version if they
3827
4221
  were edited after the pick; any other cell links to the prompt
3828
4222
  already reading its words, or starts a new, unnamed one. A use is
3829
- recorded once: a second call for the same run changes nothing."""
4223
+ recorded once: a second call for the same run changes nothing.
4224
+ Returns each recorded cell's prompt, {target, job, prompt: {id,
4225
+ name}}: the page links a cell to it, so the next edit of typed words
4226
+ is that prompt's next version rather than another unnamed one."""
3830
4227
  # The caller's connection: a Row still reads by index, as it does.
3831
4228
  db.row_factory = sqlite3.Row
4229
+ recorded = []
3832
4230
  for i, sc, k, cell in cells_of(run):
3833
4231
  sid, sname = scenario_ref(sc, i)
3834
4232
  # Echo's words are no wording under test: the item is its reply,
@@ -3851,6 +4249,9 @@ class Prompts:
3851
4249
  db.execute("INSERT OR IGNORE INTO prompt_uses (prompt_id, version, run_id, scenario_id, "
3852
4250
  "scenario_name, job, at) VALUES (?, ?, ?, ?, ?, ?, ?)",
3853
4251
  (pid, version, rid, sid, sname, k, at))
4252
+ name = db.execute("SELECT name FROM prompts WHERE id = ?", (pid,)).fetchone()[0]
4253
+ recorded.append({"target": sid, "job": k, "prompt": {"id": pid, "name": name}})
4254
+ return recorded
3854
4255
 
3855
4256
  def backfill(self, runs):
3856
4257
  """The runs from before the library, read into it once. [runs] is
@@ -5191,19 +5592,30 @@ def demo_pack() -> bytes:
5191
5592
  NO_WORDS = {"echo", "recorded", "http"}
5192
5593
 
5193
5594
 
5194
- def pack_connection(ws):
5595
+ # The built-in connection types that read a document whole, and the suffixes
5596
+ # that are one: evals-core.ts's `documents: true` and DOCUMENT_TYPES (#558).
5597
+ DOCUMENT_CONNECTIONS = ("openai-compatible", "anthropic")
5598
+ DOCUMENT_SUFFIXES = (".pdf",)
5599
+
5600
+
5601
+ def pack_connection(ws, documents=False):
5195
5602
  """The AI Connection a pack's target answers on where it names one this
5196
5603
  lab lacks (#550): the lab's grader, or else the first connection that is
5197
5604
  asked words, either shared with workspace [ws]; None where there is none,
5198
- so the target keeps its placeholder and the Run bar says to add one."""
5605
+ so the target keeps its placeholder and the Run bar says to add one. A
5606
+ pipeline that sends [documents] looks first among the connections that
5607
+ read one (#560), so the Run bar has nothing to refuse where the lab has
5608
+ one."""
5199
5609
  body = ((STORE.all().get("promptlab.connections") or {}).get("body") or {}) if STORE is not None else {}
5200
5610
  words = [c for c in body.get("list") or []
5201
5611
  if isinstance(c, dict) and isinstance(c.get("id"), str) and c["id"]
5202
5612
  and c.get("type") not in NO_WORDS and STORE.shared("profile", c["id"], ws)]
5613
+ if documents:
5614
+ words = [c for c in words if c.get("type") in DOCUMENT_CONNECTIONS] or words
5203
5615
  return next((c for c in words if c["id"] == body.get("grader")), words[0] if words else None)
5204
5616
 
5205
5617
 
5206
- def repoint_targets(doc: dict, ws) -> None:
5618
+ def repoint_targets(doc: dict, ws, documents=False) -> None:
5207
5619
  """A pack pipeline's targets that name a connection this lab does not have,
5208
5620
  pointed at the one pack_connection picks, its profile dropped with it."""
5209
5621
  targets = doc.get("targets")
@@ -5216,7 +5628,7 @@ def repoint_targets(doc: dict, ws) -> None:
5216
5628
  ref = t.get("connection") if isinstance(t, dict) else None
5217
5629
  if not isinstance(ref, dict) or ref.get("id") in have:
5218
5630
  continue
5219
- pick = pick or pack_connection(ws)
5631
+ pick = pick or pack_connection(ws, documents)
5220
5632
  if pick is None:
5221
5633
  return
5222
5634
  t["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
@@ -5611,11 +6023,15 @@ class Packs:
5611
6023
  ref.clear()
5612
6024
  ref.update({"id": did, "name": DATASETS.get(did)["name"]})
5613
6025
  content = content_of(doc)
6026
+ documents = False
5614
6027
  if isinstance(content, dict) and isinstance(content.get("ref"), dict):
5615
- sid = src_ids.get(content["ref"].get("id")) or src_ids.get(content["ref"].get("name"))
6028
+ ref = content["ref"]
6029
+ files = pack["sources"].get(ref.get("id")) or pack["sources"].get(ref.get("name")) or []
6030
+ documents = any(Path(f).suffix.lower() in DOCUMENT_SUFFIXES for f, _ in files)
6031
+ sid = src_ids.get(ref.get("id")) or src_ids.get(ref.get("name"))
5616
6032
  if sid:
5617
6033
  content["ref"] = {"id": sid, "name": SOURCES.get(sid)["name"]}
5618
- repoint_targets(doc, ws)
6034
+ repoint_targets(doc, ws, documents)
5619
6035
  new_work.append((key, doc))
5620
6036
  def place(body):
5621
6037
  body.setdefault("list", [])
@@ -6542,7 +6958,8 @@ class Queue:
6542
6958
  # a dynamic Source graded live is fetched at submit and kept here,
6543
6959
  # by item name, so the worker stays a pure dir-reader -- _materialise
6544
6960
  # writes these into the run dir rather than copying a Source file. A
6545
- # snapshot run keeps none and reads the Source's stored Calls.
6961
+ # run over a pinned response (#596) keeps that response's bytes here
6962
+ # too; a snapshot run keeps none and reads the Source's stored Calls.
6546
6963
  if "produced" not in cols:
6547
6964
  db.execute("ALTER TABLE queue ADD COLUMN produced TEXT")
6548
6965
  # The workspace a run belongs to (docs/workspaces.md): History is
@@ -6630,7 +7047,8 @@ class Queue:
6630
7047
 
6631
7048
  # ---- submit ---------------------------------------------------------
6632
7049
 
6633
- def submit(self, run: dict, dataset=None, rerun_of=None, groups=None, filter_sets=None, produced=None):
7050
+ def submit(self, run: dict, dataset=None, rerun_of=None, groups=None, filter_sets=None, produced=None,
7051
+ recorded=None):
6634
7052
  """
6635
7053
  A new queued run. `run` is the run document (docs/pipeline-model.md
6636
7054
  §5): the pipeline, the profiles it resolved to without their keys, its
@@ -6643,7 +7061,8 @@ class Queue:
6643
7061
  run a re-run was queued from. Returns the row. Its items are that list,
6644
7062
  or the one inline text, each through every scenario -- so the total is
6645
7063
  the list's length, repeats and all, the same count the runner and the
6646
- page make.
7064
+ page make. A `recorded` list is given each cell's prompt, as
7065
+ Prompts.record returns them.
6647
7066
  """
6648
7067
  rid = secrets.token_hex(6)
6649
7068
  now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -6653,7 +7072,7 @@ class Queue:
6653
7072
  "snapshot, results, progress, totals, dataset, rerun_of, groups, filter_sets, produced, workspace) "
6654
7073
  "VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
6655
7074
  (rid, "queued", now, json.dumps(run), "[]",
6656
- json.dumps({"current": None, "n": 0, "total": total}),
7075
+ json.dumps({"current": None, "at": None, "n": 0, "total": total}),
6657
7076
  json.dumps({"ran": 0, "passed": 0, "found": 0, "of": 0}),
6658
7077
  None if dataset is None else json.dumps(dataset), rerun_of,
6659
7078
  None if groups is None else json.dumps(groups),
@@ -6663,7 +7082,9 @@ class Queue:
6663
7082
  # Its prompts' uses, in the same transaction: a run is in the
6664
7083
  # library the moment it is queued, or not queued at all.
6665
7084
  if self.prompts is not None:
6666
- self.prompts.record(db, rid, run, now)
7085
+ links = self.prompts.record(db, rid, run, now)
7086
+ if recorded is not None:
7087
+ recorded.extend(links)
6667
7088
  # Read back under the lock the worker dequeues under, so the
6668
7089
  # answer is the run as it was queued: an idle worker can take it
6669
7090
  # the moment the lock is let go.
@@ -6726,7 +7147,8 @@ class Queue:
6726
7147
  def produced(self, rid):
6727
7148
  """A live dynamic run's produced item bytes, by item name, or None for
6728
7149
  a run that produced none (a snapshot run, or any non-dynamic run). The
6729
- values are the text the server fetched at submit, kept so a start, a
7150
+ values are the text the server fetched at submit, or the pinned
7151
+ response's (#596), kept so a start, a
6730
7152
  resume and a re-run grade the same bytes (docs/dynamic-sources.md)."""
6731
7153
  with self.lock, closing(sqlite3.connect(self.store.path)) as db:
6732
7154
  r = db.execute("SELECT produced FROM queue WHERE id = ?", (rid,)).fetchone()
@@ -7088,7 +7510,8 @@ class Queue:
7088
7510
  # A live dynamic run carries its items' bytes with the row, fetched at
7089
7511
  # submit (docs/dynamic-sources.md): they are written into the run dir
7090
7512
  # rather than copied from a Source, so the worker still just reads a
7091
- # directory of files. A snapshot run has none and reads the Source.
7513
+ # directory of files. A pinned run (#596) carries its response's bytes
7514
+ # the same way. A snapshot run has none and reads the Source.
7092
7515
  live = self.produced(run["id"])
7093
7516
  rundir = self.dir / run["id"]
7094
7517
  files_dir = rundir / "files"
@@ -7246,13 +7669,15 @@ class Queue:
7246
7669
  continue
7247
7670
  if obj.get("event") == "start":
7248
7671
  # The item the worker has just picked up, named before
7249
- # it is asked: what "current" means to a reader.
7672
+ # it is asked: what "current" means to a reader. Its
7673
+ # place too, since an item can repeat: the row in flight.
7250
7674
  with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
7251
7675
  cur = db.execute("SELECT progress FROM queue WHERE id = ?", (rid,)).fetchone()
7252
7676
  if cur is None:
7253
7677
  return
7254
7678
  progress = json.loads(cur[0])
7255
7679
  progress["current"] = obj.get("name")
7680
+ progress["at"] = obj["item"] if isinstance(obj["item"], int) else None
7256
7681
  db.execute("UPDATE queue SET progress = ? WHERE id = ?",
7257
7682
  (json.dumps(progress), rid))
7258
7683
  continue
@@ -7336,6 +7761,13 @@ class Queue:
7336
7761
  with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
7337
7762
  db.execute("UPDATE queue SET status = ?, finished_at = ?, error = ?, "
7338
7763
  "totals = ? WHERE id = ?", (status, now, error, json.dumps(totals), rid))
7764
+ # A terminal run has no row in flight.
7765
+ cur = db.execute("SELECT progress FROM queue WHERE id = ?", (rid,)).fetchone()
7766
+ if cur is not None:
7767
+ progress = json.loads(cur[0])
7768
+ if progress.get("at") is not None:
7769
+ progress["at"] = None
7770
+ db.execute("UPDATE queue SET progress = ? WHERE id = ?", (json.dumps(progress), rid))
7339
7771
 
7340
7772
  def _read_report(self, rid):
7341
7773
  """The worker's final report, which never shares the progress stream:
@@ -7556,10 +7988,14 @@ def worker_destinations(run: dict):
7556
7988
  # (answer_key), each entry naming the connection whose key it takes (#445).
7557
7989
  # 17: an HTTP Request target has no `api` of its own; its job's flow step's
7558
7990
  # is the one its body is read by (#477).
7559
- PIPELINE_VERSION = 17
7991
+ # 18: a dynamic Source's content graded as saved pins one response, `pin:
7992
+ # {name, n}`, which a fetch, an edit or a Restore never moves (#596).
7993
+ # 19: a job's filters are one list around its Response Format, and filters are
7994
+ # seven verbs (#615).
7995
+ PIPELINE_VERSION = 19
7560
7996
  # What a stored run may be: the current version, and the ones evals-core.ts's
7561
7997
  # upgradePipeline reads. A new submission is upgraded to the current one.
7562
- READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
7998
+ READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19)
7563
7999
  TARGET_CAP = 4
7564
8000
  # Target steps whose words the Prompt library does not record as a use: they
7565
8001
  # ask no model (evals-core.ts's STEP_TYPES.echo).
@@ -7990,6 +8426,10 @@ def run_problems(run):
7990
8426
  bad.append("a run over a Source needs its file list")
7991
8427
  elif not isinstance(files, list) or not files or not all(isinstance(f, str) for f in files):
7992
8428
  bad.append("a run over a Source needs its file list")
8429
+ pin = content.get("pin")
8430
+ if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
8431
+ and type(pin.get("n")) is int and pin["n"] >= 0):
8432
+ bad.append("a Source content's pin names a response as { name, n }")
7993
8433
  elif kind == "text":
7994
8434
  if not isinstance(content.get("text"), str) or not content["text"].strip():
7995
8435
  bad.append("a run over text needs some text")
@@ -9056,7 +9496,18 @@ class Handler(BaseHTTPRequestHandler):
9056
9496
  live = content.get("live")
9057
9497
  if live is None:
9058
9498
  live = str(cfg.get("live")).lower() in ("1", "true", "yes", "on") or not src.get("files")
9059
- if live:
9499
+ if live and content["files"]:
9500
+ # Live data over held items (#587): each response the
9501
+ # document names is fetched again under its own name,
9502
+ # so its case applies; a copy, an upload or an item
9503
+ # written by hand is graded as saved.
9504
+ produced, err = self._produce_endpoints(sid, entry, cfg, content["files"])
9505
+ if err:
9506
+ return self._json(err[0], {"error": err[1]})
9507
+ content["live"] = True
9508
+ content["revs"] = {name: "live:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
9509
+ for name, text in produced.items()}
9510
+ elif live:
9060
9511
  produced, name, err = self._produce_live(sid, entry, cfg)
9061
9512
  if err:
9062
9513
  return self._json(err[0], {"error": err[1]})
@@ -9070,6 +9521,20 @@ class Handler(BaseHTTPRequestHandler):
9070
9521
  produced[name].encode("utf-8")).hexdigest()}
9071
9522
  else:
9072
9523
  content["live"] = False
9524
+ pin = content.get("pin")
9525
+ if not content["live"] and isinstance(pin, dict):
9526
+ # Saved response pins one (#596): the run grades that
9527
+ # response's bytes, kept with the row as a live run's are,
9528
+ # whatever a fetch, an edit or a Restore made current since.
9529
+ body = SOURCES.pinned_body(sid, pin["name"], pin["n"])
9530
+ if body is None:
9531
+ return self._json(400, {"error": f"the response this run grades, {pin['name']}, "
9532
+ "was removed: pick another"})
9533
+ text = body.decode("utf-8", errors="replace")
9534
+ produced = {pin["name"]: text}
9535
+ loops = content.get("loops")
9536
+ content["files"] = [pin["name"]] * (loops if type(loops) is int and loops >= 1 else 1)
9537
+ content["revs"] = {pin["name"]: "pin:" + hashlib.sha256(body).hexdigest()}
9073
9538
  if produced is None:
9074
9539
  # Snapshot (or a non-dynamic Source): pin the Source's own files
9075
9540
  # at their current revision, the server's to say whatever the
@@ -9136,8 +9601,49 @@ class Handler(BaseHTTPRequestHandler):
9136
9601
  # The worker reads a body per group now (#233), so a run may link
9137
9602
  # several; each body is kept with the row under `<id>@<n>`. A live
9138
9603
  # dynamic run carries its produced item bytes too.
9139
- return self._json(201, {"run": QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None,
9140
- produced=produced)})
9604
+ # Each target step's prompt, as recorded: a step typed with no `from`
9605
+ # takes it, so its next edit versions that prompt (docs/pipeline-model.md §14).
9606
+ recorded = []
9607
+ row = QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None, produced=produced,
9608
+ recorded=recorded)
9609
+ return self._json(201, {"run": row, "prompts": recorded})
9610
+
9611
+ @staticmethod
9612
+ def _produce_endpoints(sid, entry, cfg, files):
9613
+ """Every item [files] names, for a live run over a Source that holds
9614
+ some (#587): a response -- a Call fetched from a request -- fetched
9615
+ now from the request it was, under its own name; any other item (a
9616
+ copy, an upload, one by hand) its saved bytes. Returns ({name: text},
9617
+ None), or (None, (code, sentence)) -- a fetch that fails refuses the
9618
+ submit, naming the response."""
9619
+ rows = {c["name"]: c for c in SOURCES.calls(sid) or []}
9620
+ out = {}
9621
+ for name in dict.fromkeys(files):
9622
+ call = rows.get(name) or {}
9623
+ request = call.get("request") if isinstance(call.get("request"), dict) else {}
9624
+ if call.get("origin") in ("snapshot", "run", "edited") and request.get("url"):
9625
+ # The request it was: a response whose Source has moved on
9626
+ # since is still asked what it was asked.
9627
+ result = entry["produce"]({**cfg, **{k: request[k] for k in ("url", "method")
9628
+ if isinstance(request.get(k), str)}})
9629
+ if result.get("refused"):
9630
+ return None, result["refused"]
9631
+ if result.get("content") is None:
9632
+ return None, (502, f"the live fetch of {name} produced nothing: "
9633
+ + (result.get("error") or "unknown"))
9634
+ kept, take_err = entry["take"](name, result["content"])
9635
+ if take_err:
9636
+ return None, take_err
9637
+ out[name] = kept.decode("utf-8")
9638
+ continue
9639
+ path = SOURCES.file_path(sid, name)
9640
+ if path is None or not path.is_file():
9641
+ return None, (400, f"{name!r} is not in that Source")
9642
+ try:
9643
+ out[name] = path.read_text("utf-8")
9644
+ except (OSError, UnicodeDecodeError):
9645
+ return None, (400, f"{name} is not text")
9646
+ return out, None
9141
9647
 
9142
9648
  @staticmethod
9143
9649
  def _produce_live(sid, entry, cfg):
@@ -9507,14 +10013,17 @@ class Handler(BaseHTTPRequestHandler):
9507
10013
  parts = path.split("/")
9508
10014
  if len(parts) == 3:
9509
10015
  sources = SOURCES.list()
9510
- return self._json(200, {"sources": sources, "cap": MAX_SOURCE,
9511
- "used": sum(s["bytes"] for s in sources if not s["system"])})
10016
+ return self._json(200, {"sources": sources, "cap": MAX_SOURCE})
9512
10017
  if len(parts) == 4:
9513
10018
  source = SOURCES.get(parts[3])
9514
10019
  return self._json(200, source) if source else self._send(404, b"not found", "text/plain")
9515
10020
  if len(parts) == 5 and parts[4] == "definition":
9516
10021
  got = SOURCES.definition(parts[3])
9517
10022
  return self._json(200, got) if got else self._send(404, b"not found", "text/plain")
10023
+ if len(parts) == 5 and parts[4] == "responses":
10024
+ held = SOURCES.responses(parts[3])
10025
+ return self._json(200, {"responses": held}) if held is not None \
10026
+ else self._send(404, b"not found", "text/plain")
9518
10027
  if len(parts) == 5 and parts[4] == "calls":
9519
10028
  calls = SOURCES.calls(parts[3])
9520
10029
  return self._json(200, {"calls": calls}) if calls is not None \
@@ -9590,7 +10099,17 @@ class Handler(BaseHTTPRequestHandler):
9590
10099
  if err:
9591
10100
  code, message = err
9592
10101
  return self._json(code, {"error": message})
9593
- return self._json(201, {"call": call})
10102
+ return self._json(201 if call["outcome"] == "added" else 200, {"call": call})
10103
+ if len(parts) == 6 and parts[4] == "responses" and parts[5] == "restore":
10104
+ payload = self._payload() or {}
10105
+ name = source_name(payload.get("name"))
10106
+ n, drop = payload.get("n"), payload.get("drop")
10107
+ if name is None or type(n) is not int or not (drop is None or type(drop) is int):
10108
+ return self._json(400, {"error": "a restore is { name, n, drop? }"})
10109
+ got, err = SOURCES.restore_response(parts[3], name, n, drop)
10110
+ if err:
10111
+ return self._json(err[0], {"error": err[1]})
10112
+ return self._json(200, {"call": got})
9594
10113
  if len(parts) == 6 and parts[4] == "calls" and parts[5] == "remove":
9595
10114
  names = (self._payload() or {}).get("names")
9596
10115
  if not isinstance(names, list) or not names:
@@ -9683,12 +10202,21 @@ class Handler(BaseHTTPRequestHandler):
9683
10202
  if len(body) != length:
9684
10203
  return self._json(400, {"error": "the body ended before Content-Length promised"})
9685
10204
  # A type that checks what it takes (a flow's records) has it first.
9686
- take = (SOURCES.type_of(sid) or {}).get("take")
10205
+ entry = SOURCES.type_of(sid) or {}
10206
+ take = entry.get("take")
9687
10207
  if take is not None:
9688
10208
  body, err = take(name, body)
9689
10209
  if err:
9690
10210
  code, message = err
9691
10211
  return self._json(code, {"error": message})
10212
+ # One a kind already holds by its key (a record of the same run's call,
10213
+ # edited or not) is not kept twice (#587).
10214
+ key = entry.get("key")
10215
+ value = key(body) if key is not None else None
10216
+ if value is not None:
10217
+ held = SOURCES.key_held(sid, key, value)
10218
+ if held is not None:
10219
+ return self._json(200, {"source": sid, "name": held, "unchanged": True})
9692
10220
  stored, err = SOURCES.add_file(sid, name, body)
9693
10221
  if err:
9694
10222
  code, message = err
@@ -9783,11 +10311,16 @@ class Handler(BaseHTTPRequestHandler):
9783
10311
  # key that comes is the connection's from then on, a rotated one included.
9784
10312
  # A connection carries its share set (`shares`, workspace slugs and the
9785
10313
  # sentinels; absent is All) from version 2: version 1 carried none, so an
9786
- # import reads its connections as All.
10314
+ # import reads its connections as All. Version 3 carries (Default)'s settings
10315
+ # (`connectionProfileDefault`, #603), which the profiles store keeps beside
10316
+ # its list; an import sets them only on a lab whose (Default) sets nothing.
9787
10317
 
9788
10318
  LAB_FORMAT = "evals-lab/lab"
9789
- LAB_VERSIONS = (1, 2)
9790
- LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "grader", "workspaces")
10319
+ LAB_VERSIONS = (1, 2, 3)
10320
+ LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "connectionProfileDefault",
10321
+ "grader", "workspaces")
10322
+ # What a Connection Profile holds that is not a setting, so (Default) holds none.
10323
+ PROFILE_META = ("id", "name", "connection", "fromProfile")
9791
10324
 
9792
10325
 
9793
10326
  def lab_export(only=None) -> dict:
@@ -9838,6 +10371,9 @@ def lab_export(only=None) -> dict:
9838
10371
  if (isinstance(body.get("grader"), str) and body["grader"] and body["grader"] not in nowhere
9839
10372
  and (not only or body["grader"] in kept)):
9840
10373
  out["grader"] = body["grader"]
10374
+ # (Default) answers on any connection, so every workspace's file has it.
10375
+ if isinstance(profiles.get("default"), dict) and profiles["default"]:
10376
+ out["connectionProfileDefault"] = profiles["default"]
9841
10377
  return out
9842
10378
 
9843
10379
 
@@ -9894,6 +10430,19 @@ def lab_problems(doc) -> str:
9894
10430
  why = _keyless(p, what)
9895
10431
  if why:
9896
10432
  return why
10433
+ if "connectionProfileDefault" in doc:
10434
+ d = doc["connectionProfileDefault"]
10435
+ if doc["version"] < 3:
10436
+ return f"a lab file of version {doc['version']} holds no connectionProfileDefault"
10437
+ if not isinstance(d, dict):
10438
+ return "the connection profile (Default) is an object of settings"
10439
+ bad = next((k for k, v in d.items() if k in PROFILE_META or not isinstance(v, str)), None)
10440
+ if bad:
10441
+ return f"the connection profile (Default)'s {bad} is not a setting"
10442
+ # A profile's key is its connection's, and (Default) names none.
10443
+ held = next((k for k, v in d.items() if LOOKS_LIKE_A_KEY.match(k) and v), None)
10444
+ if held:
10445
+ return f"the connection profile (Default) holds a {held}; a lab file holds no key"
9897
10446
  if "grader" in doc and not isinstance(doc["grader"], str):
9898
10447
  return "the grader is not a connection's id"
9899
10448
  seen = set()
@@ -9992,6 +10541,10 @@ def lab_import(doc, keys) -> tuple:
9992
10541
  profs.append(dict(p))
9993
10542
  pchanged = True
9994
10543
  lines.append(f"connection profile {p['name']} added")
10544
+ if doc.get("connectionProfileDefault") and not pbody.get("default"):
10545
+ pbody["default"] = dict(doc["connectionProfileDefault"])
10546
+ pchanged = True
10547
+ lines.append("connection profile (Default) set")
9995
10548
  if doc.get("grader") and not body.get("grader"):
9996
10549
  body["grader"] = doc["grader"]
9997
10550
  changed = True