evals-lab 0.13.0 → 0.14.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 (48) hide show
  1. package/CHANGELOG.md +39 -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 +4 -4
  15. package/lab/demo/pipelines/demo-2.json +4 -4
  16. package/lab/demo/pipelines/documents.json +102 -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 +217 -48
  32. package/lab/flows/record.mjs +5 -4
  33. package/lab/kinds/list.mjs +72 -21
  34. package/lab/run-evals.js +3 -1
  35. package/lab/server.py +536 -85
  36. package/lab/web/dist/assets/gallery-qrf0ZGXK.js +5 -0
  37. package/lab/web/dist/assets/main-D6xARg2A.js +16 -0
  38. package/lab/web/dist/assets/main-xVU6J7Gi.css +1 -0
  39. package/lab/web/dist/assets/tokens-C8dqXDHN.js +62 -0
  40. package/lab/web/dist/assets/tokens-tQ7zTPkd.css +1 -0
  41. package/lab/web/dist/gallery.html +3 -3
  42. package/lab/web/dist/index.html +4 -4
  43. package/package.json +1 -1
  44. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +0 -5
  45. package/lab/web/dist/assets/main-BSw-jyYD.css +0 -1
  46. package/lab/web/dist/assets/main-CrB8wPZn.js +0 -17
  47. package/lab/web/dist/assets/tokens-CrjDMCbz.js +0 -60
  48. 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
+ """Fetch now (#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,231 @@ 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
+ return {"name": name, "outcome": "unchanged", "status": status}, None
2665
+ added, err = self._respond(sid, name, kept, "fetched", status)
2666
+ if err:
2667
+ return None, err
2668
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2669
+ db.execute("UPDATE source_calls SET request = ?, provider = ?, error = NULL, file = ? "
2670
+ "WHERE source = ? AND name = ?",
2671
+ (json.dumps(request) if request else None, provider, name, sid, name))
2672
+ return {"name": name, "outcome": "fetched", "provider": provider, "request": request,
2673
+ "status": status, "bytes": len(kept), **added}, None
2674
+
2675
+ # ---- an item's responses (#587) -----------------------------------------
2676
+ #
2677
+ # A Call or a record keeps its responses: each fetch, import or edit is
2678
+ # one, kept with its bytes, and the newest -- or the one Restore picks --
2679
+ # is current: its bytes are the file a run grades. Nothing forks; a copy
2680
+ # (Clone) is a new item with a response of its own.
2681
+
2682
+ # How many responses an item keeps: the oldest that is neither current nor
2683
+ # pinned by a pipeline (#596) goes.
2684
+ RESPONSES_KEPT = 20
2685
+ # The kind of response a Call's provenance row says its file was.
2686
+ ORIGIN_KINDS = {"snapshot": "fetched", "run": "fetched", "edited": "edited",
2687
+ "upload": "uploaded", "hand": "added", "copy": "copied"}
2688
+ RESPONSE_KINDS = ("fetched", "recorded", "uploaded", "added", "edited", "copied")
2689
+
2690
+ def _described(self, sid, name, data):
2691
+ """What an item's bytes were before it kept responses: from its Call's
2692
+ provenance row, else from its kind's `describe` hook (a record says
2693
+ whether production recorded it), else an upload, as of the file."""
2592
2694
  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}"
2695
+ row = db.execute("SELECT origin, at, status FROM source_calls WHERE source = ? AND name = ?",
2696
+ (sid, name)).fetchone()
2697
+ f = db.execute("SELECT at FROM source_files WHERE source = ? AND name = ?",
2698
+ (sid, name)).fetchone()
2699
+ if row is not None:
2700
+ return {"kind": self.ORIGIN_KINDS.get(row[0], "uploaded"), "at": row[1], "status": row[2]}
2701
+ describe = (self.type_of(sid) or {}).get("describe")
2702
+ got = describe(data) if describe else None
2703
+ out = {"kind": "uploaded", "at": f[0] if f else None, "status": None}
2704
+ if isinstance(got, dict):
2705
+ out.update({k: v for k, v in got.items() if v is not None})
2706
+ return out
2707
+
2708
+ def _respond(self, sid, name, data, kind, status):
2709
+ """[data] as [name]'s new current response: the file rewritten in
2710
+ place, as an edit always was, and the row added. An item with no rows
2711
+ yet first gains one for the bytes it held. Returns ({n, was}, None),
2712
+ or (None, error)."""
2713
+ path = self.file_path(sid, name)
2714
+ if path is None or not path.is_file():
2715
+ return None, (404, f"{name} is not in that Source")
2716
+ old = path.read_bytes()
2717
+ first = None
2718
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2719
+ seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
2720
+ (sid, name)).fetchone() is not None
2721
+ if not seeded:
2722
+ first = self._described(sid, name, old)
2723
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2724
+ folder = self.dir / sid
2725
+ with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2726
+ f.write(data)
2727
+ tmp = f.name
2728
+ try:
2729
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2730
+ total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
2731
+ if total + len(data) - len(old) > MAX_SOURCE:
2732
+ raise _Refused((413, "that would take the source past its cap"))
2733
+ n, was = self._response_rows(db, sid, name, old, first, data, kind, status, now)
2734
+ db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
2735
+ (len(data), sid, name))
2736
+ db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2737
+ except _Refused as no:
2738
+ os.unlink(tmp)
2739
+ return None, no.err
2740
+ os.replace(tmp, folder / name)
2741
+ return {"n": n, "was": was}, None
2742
+
2743
+ def _response_rows(self, db, sid, name, old, first, data, kind, status, now):
2744
+ """The rows behind _respond and an edit, inside the caller's
2745
+ transaction: the bytes [name] held as its first response when it had
2746
+ none ([first] describes them), [data] as the new current one, the
2747
+ oldest past RESPONSES_KEPT dropped -- never one a pipeline pins
2748
+ (#596), which does not count against the rest. Returns (n, was)."""
2749
+ if first is not None:
2750
+ db.execute("INSERT OR IGNORE INTO source_responses (source, name, n, at, kind, status, "
2751
+ "body, current) VALUES (?, ?, 1, ?, ?, ?, ?, 1)",
2752
+ (sid, name, first["at"] or now, first["kind"], first["status"], old))
2753
+ was = db.execute("SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 1",
2754
+ (sid, name)).fetchone()
2755
+ n = db.execute("SELECT COALESCE(MAX(n), 0) + 1 FROM source_responses WHERE source = ? "
2756
+ "AND name = ?", (sid, name)).fetchone()[0]
2757
+ db.execute("UPDATE source_responses SET current = 0 WHERE source = ? AND name = ?", (sid, name))
2758
+ db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
2759
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1)", (sid, name, n, now, kind, status, data))
2760
+ pinned = response_pins(db, sid, name)
2761
+ loose = [r for (r,) in db.execute(
2762
+ "SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 0 "
2763
+ "ORDER BY n DESC", (sid, name)).fetchall() if r not in pinned]
2764
+ for gone in loose[self.RESPONSES_KEPT - 1:]:
2765
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?", (sid, name, gone))
2766
+ db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?", (status, sid, name))
2767
+ return n, (was[0] if was else None)
2768
+
2769
+ def pinned_body(self, sid, name, n):
2770
+ """The bytes of [name]'s response [n] a run's pin names (#596), or
2771
+ None when the response is gone. `n` 0 is an item that kept one
2772
+ response and no rows: its file, or once it gained rows, the first,
2773
+ which holds those bytes."""
2774
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2775
+ row = db.execute("SELECT body FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2776
+ (sid, name, n or 1)).fetchone()
2777
+ seeded = row is not None or db.execute(
2778
+ "SELECT 1 FROM source_responses WHERE source = ? AND name = ?", (sid, name)).fetchone() is not None
2779
+ if row is not None:
2780
+ return bytes(row[0])
2781
+ if n == 0 and not seeded:
2782
+ path = self.file_path(sid, name)
2783
+ return path.read_bytes() if path is not None and path.is_file() else None
2784
+ return None
2785
+
2786
+ def responses(self, sid):
2787
+ """Every item's responses, newest first, by name: {n, at, kind,
2788
+ status, current} -- no bytes. An item that has kept one response has
2789
+ no rows, and reads as that one, `n` 0, described as it was made. None
2790
+ for no such Source."""
2791
+ if self.get(sid) is None:
2792
+ return None
2793
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2794
+ names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
2795
+ rows = db.execute("SELECT name, n, at, kind, status, current FROM source_responses "
2796
+ "WHERE source = ? ORDER BY n DESC", (sid,)).fetchall()
2797
+ out = {}
2798
+ for name, n, at, kind, status, current in rows:
2799
+ out.setdefault(name, []).append({"n": n, "at": at, "kind": kind, "status": status,
2800
+ "current": bool(current)})
2801
+ for name in names:
2802
+ if name not in out:
2803
+ path = self.file_path(sid, name)
2804
+ data = path.read_bytes() if path is not None and path.is_file() else b""
2805
+ out[name] = [{"n": 0, **self._described(sid, name, data), "current": True}]
2806
+ return out
2807
+
2808
+ def restore_response(self, sid, name, n, drop=None):
2809
+ """Makes [name]'s response [n] current: its bytes the file again, the
2810
+ response it kept, not a new one. [drop] removes a response outright as
2811
+ well -- Undo of a fetch or an edit takes back the one it added. Returns
2812
+ ({name, n}, None), or (None, error)."""
2813
+ entry, err = self._calls_entry(sid)
2814
+ if err:
2815
+ return None, err
2816
+ path = self.file_path(sid, name)
2817
+ if path is None or not path.is_file():
2818
+ return None, (404, f"{name} is not in that Source")
2819
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2820
+ row = db.execute("SELECT body, status FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2821
+ (sid, name, n)).fetchone()
2822
+ if row is None:
2823
+ return None, (404, f"{name} has no such response")
2824
+ data, status = bytes(row[0]), row[1]
2825
+ old = path.read_bytes()
2826
+ folder = self.dir / sid
2827
+ with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2828
+ f.write(data)
2829
+ tmp = f.name
2830
+ try:
2831
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2832
+ total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
2833
+ if total + len(data) - len(old) > MAX_SOURCE:
2834
+ raise _Refused((413, "that would take the source past its cap"))
2835
+ db.execute("UPDATE source_responses SET current = (n = ?) WHERE source = ? AND name = ?",
2836
+ (n, sid, name))
2837
+ if drop is not None and drop != n:
2838
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2839
+ (sid, name, drop))
2840
+ db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
2841
+ (len(data), sid, name))
2842
+ db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2843
+ db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?",
2844
+ (status, sid, name))
2845
+ except _Refused as no:
2846
+ os.unlink(tmp)
2847
+ return None, no.err
2848
+ os.replace(tmp, folder / name)
2849
+ return {"name": name, "n": n}, None
2850
+
2851
+ def key_held(self, sid, key, value):
2852
+ """The name of the file of [sid] whose [key] (its kind's `key` hook)
2853
+ is [value], or None: a record from a run already held is not kept a
2854
+ second time, however it was edited since."""
2855
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2856
+ names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
2857
+ for name in names:
2858
+ path = self.file_path(sid, name)
2859
+ try:
2860
+ if path is not None and key(path.read_bytes()) == value:
2861
+ return name
2862
+ except OSError:
2863
+ continue
2864
+ return None
2602
2865
 
2603
2866
  # ---- a Call opened in the Call dialog: edited, renamed, cloned (#438) ---
2604
2867
  #
@@ -2608,7 +2871,7 @@ class Sources:
2608
2871
  # the rest redacted. A File Library has no hook and no Calls.
2609
2872
 
2610
2873
  # The origins a dynamic Call's provenance row may be put back to (Undo).
2611
- CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited")
2874
+ CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited", "copy")
2612
2875
 
2613
2876
  def _calls_entry(self, sid):
2614
2877
  """The enforcement entry of a user Source whose files are Calls, or
@@ -2636,11 +2899,13 @@ class Sources:
2636
2899
  the Source's `take` hook as an upload does. A rename keeps the
2637
2900
  extension and refuses a name in use, or a Call that a dataset's cases
2638
2901
  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."""
2902
+ item by name. Changed text is a new response, `edited` -- a rename
2903
+ alone is no edit -- and the item's responses follow a rename. [origin]
2904
+ puts back a dynamic Call's provenance (an older page's Undo). Nothing
2905
+ is written unless all of it passes. Returns ({name, origin, bytes} and,
2906
+ for an edit, the response it added `n` and the one it replaced `was`,
2907
+ None), or (None, (code, sentence)) -- (code, sentence, "name") when
2908
+ the Name field is what is wrong."""
2644
2909
  entry, err = self._calls_entry(sid)
2645
2910
  if err:
2646
2911
  return None, err
@@ -2677,12 +2942,26 @@ class Sources:
2677
2942
  if take_err:
2678
2943
  return None, take_err
2679
2944
  changed = data != old
2945
+ first = status = None
2946
+ if changed:
2947
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2948
+ seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
2949
+ (sid, name)).fetchone() is not None
2950
+ held = db.execute("SELECT status FROM source_calls WHERE source = ? AND name = ?",
2951
+ (sid, name)).fetchone()
2952
+ if not seeded:
2953
+ first = self._described(sid, name, old)
2954
+ # An edited reply says its own status (a record's); an edited Call
2955
+ # keeps the status of the fetch it was.
2956
+ described = entry["describe"](data) if entry.get("describe") else None
2957
+ status = (described or {}).get("status") if described is not None else (held[0] if held else None)
2680
2958
  folder = self.dir / sid
2681
2959
  tmp = None
2682
2960
  if changed or new != name:
2683
2961
  with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2684
2962
  f.write(data)
2685
2963
  tmp = f.name
2964
+ added = {}
2686
2965
  try:
2687
2966
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2688
2967
  if new != name and new in self._names_held(db, sid):
@@ -2693,13 +2972,18 @@ class Sources:
2693
2972
  db.execute("UPDATE source_files SET name = ?, bytes = ? WHERE source = ? AND name = ?",
2694
2973
  (new, len(data), sid, name))
2695
2974
  db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2975
+ db.execute("UPDATE source_responses SET name = ? WHERE source = ? AND name = ?", (new, sid, name))
2696
2976
  row = db.execute("SELECT origin FROM source_calls WHERE source = ? AND name = ?",
2697
2977
  (sid, name)).fetchone()
2698
2978
  now = None
2699
2979
  if row is not None:
2700
- now = origin or ("edited" if changed else row[0])
2980
+ now = origin or row[0]
2701
2981
  db.execute("UPDATE source_calls SET name = ?, file = ?, origin = ? "
2702
2982
  "WHERE source = ? AND name = ?", (new, new, now, sid, name))
2983
+ if changed:
2984
+ n, was = self._response_rows(db, sid, new, old, first, data, "edited", status,
2985
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
2986
+ added = {"n": n, "was": was}
2703
2987
  except _Refused as no:
2704
2988
  if tmp:
2705
2989
  os.unlink(tmp)
@@ -2708,13 +2992,14 @@ class Sources:
2708
2992
  os.replace(tmp, folder / new)
2709
2993
  if new != name:
2710
2994
  (folder / name).unlink(missing_ok=True)
2711
- return {"name": new, "origin": now, "bytes": len(data)}, None
2995
+ return {"name": new, "origin": now, "bytes": len(data), **added}, None
2712
2996
 
2713
2997
  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)."""
2998
+ """A copy of one Call under `<stem>-copy` (then `-copy-2`, ...): its
2999
+ current bytes as the copy's one response, `copied`, and for a dynamic
3000
+ Call its request and provider, its origin `copy` -- so a fetch never
3001
+ reaches it and a live run keeps it as saved (#587). Returns ({name},
3002
+ None), or (None, error)."""
2718
3003
  entry, err = self._calls_entry(sid)
2719
3004
  if err:
2720
3005
  return None, err
@@ -2731,13 +3016,18 @@ class Sources:
2731
3016
  if copy not in held:
2732
3017
  break
2733
3018
  n += 1
2734
- stored, err = self.add_file(sid, copy, path.read_bytes())
3019
+ data = path.read_bytes()
3020
+ stored, err = self.add_file(sid, copy, data)
2735
3021
  if err:
2736
3022
  return None, err
3023
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2737
3024
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2738
3025
  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))
3026
+ "SELECT source, ?, 'copy', ?, provider, request, status, error, ? "
3027
+ "FROM source_calls WHERE source = ? AND name = ?", (stored, now, stored, sid, name))
3028
+ # A copy starts its own responses: a fetch never reaches it.
3029
+ db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
3030
+ "VALUES (?, ?, 1, ?, 'copied', NULL, ?, 1)", (sid, stored, now, data))
2741
3031
  return {"name": stored}, None
2742
3032
 
2743
3033
  def remove_calls(self, sid, names):
@@ -2833,6 +3123,14 @@ class Sources:
2833
3123
  "VALUES (?, ?, ?, ?)",
2834
3124
  (sid, f["name"], f.get("bytes", 0), f.get("at", "")))
2835
3125
  db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (total, sid))
3126
+ # An entry from before calls and responses were trashed has neither.
3127
+ for c in m.get("calls") or []:
3128
+ db.execute("INSERT OR REPLACE INTO source_calls (source, name, origin, at, provider, "
3129
+ "request, status, error, file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (sid, *c))
3130
+ for r in m.get("responses") or []:
3131
+ db.execute("INSERT OR REPLACE INTO source_responses (source, name, n, at, kind, status, "
3132
+ "body, current) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
3133
+ (sid, r[0], r[1], r[2], r[3], r[4], base64.b64decode(r[5]), r[6]))
2836
3134
  th = self.dir / sid / ".thumbs"
2837
3135
  th.mkdir(parents=True, exist_ok=True)
2838
3136
  for f in files:
@@ -3498,6 +3796,26 @@ def pins_in(workflows) -> set:
3498
3796
  return out
3499
3797
 
3500
3798
 
3799
+ def response_pins(db, sid, name) -> set:
3800
+ """Every response `n` of Source [sid]'s item [name] a stored pipeline pins
3801
+ (#596), across every workspace's promptlab.workflows document, read in
3802
+ the caller's transaction. A pin of `n` 0 -- an item before it kept rows --
3803
+ pins the first row, which holds those bytes."""
3804
+ out = set()
3805
+ for (body,) in db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows'"):
3806
+ try:
3807
+ listed = (json.loads(body) or {}).get("list") if body else None
3808
+ except (ValueError, TypeError, AttributeError):
3809
+ continue
3810
+ for w in listed if isinstance(listed, list) else []:
3811
+ c = content_of(w.get("work")) if isinstance(w, dict) else None
3812
+ pin = c.get("pin") if isinstance(c, dict) else None
3813
+ if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
3814
+ and pin.get("name") == name and type(pin.get("n")) is int):
3815
+ out.add(pin["n"] or 1)
3816
+ return out
3817
+
3818
+
3501
3819
  def group_key(ref) -> str:
3502
3820
  """The key a run keeps a group's body under: `<id>@<n>`, or the id alone
3503
3821
  for a reference from before the lab numbered versions."""
@@ -5191,19 +5509,30 @@ def demo_pack() -> bytes:
5191
5509
  NO_WORDS = {"echo", "recorded", "http"}
5192
5510
 
5193
5511
 
5194
- def pack_connection(ws):
5512
+ # The built-in connection types that read a document whole, and the suffixes
5513
+ # that are one: evals-core.ts's `documents: true` and DOCUMENT_TYPES (#558).
5514
+ DOCUMENT_CONNECTIONS = ("openai-compatible", "anthropic")
5515
+ DOCUMENT_SUFFIXES = (".pdf",)
5516
+
5517
+
5518
+ def pack_connection(ws, documents=False):
5195
5519
  """The AI Connection a pack's target answers on where it names one this
5196
5520
  lab lacks (#550): the lab's grader, or else the first connection that is
5197
5521
  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."""
5522
+ so the target keeps its placeholder and the Run bar says to add one. A
5523
+ pipeline that sends [documents] looks first among the connections that
5524
+ read one (#560), so the Run bar has nothing to refuse where the lab has
5525
+ one."""
5199
5526
  body = ((STORE.all().get("promptlab.connections") or {}).get("body") or {}) if STORE is not None else {}
5200
5527
  words = [c for c in body.get("list") or []
5201
5528
  if isinstance(c, dict) and isinstance(c.get("id"), str) and c["id"]
5202
5529
  and c.get("type") not in NO_WORDS and STORE.shared("profile", c["id"], ws)]
5530
+ if documents:
5531
+ words = [c for c in words if c.get("type") in DOCUMENT_CONNECTIONS] or words
5203
5532
  return next((c for c in words if c["id"] == body.get("grader")), words[0] if words else None)
5204
5533
 
5205
5534
 
5206
- def repoint_targets(doc: dict, ws) -> None:
5535
+ def repoint_targets(doc: dict, ws, documents=False) -> None:
5207
5536
  """A pack pipeline's targets that name a connection this lab does not have,
5208
5537
  pointed at the one pack_connection picks, its profile dropped with it."""
5209
5538
  targets = doc.get("targets")
@@ -5216,7 +5545,7 @@ def repoint_targets(doc: dict, ws) -> None:
5216
5545
  ref = t.get("connection") if isinstance(t, dict) else None
5217
5546
  if not isinstance(ref, dict) or ref.get("id") in have:
5218
5547
  continue
5219
- pick = pick or pack_connection(ws)
5548
+ pick = pick or pack_connection(ws, documents)
5220
5549
  if pick is None:
5221
5550
  return
5222
5551
  t["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
@@ -5611,11 +5940,15 @@ class Packs:
5611
5940
  ref.clear()
5612
5941
  ref.update({"id": did, "name": DATASETS.get(did)["name"]})
5613
5942
  content = content_of(doc)
5943
+ documents = False
5614
5944
  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"))
5945
+ ref = content["ref"]
5946
+ files = pack["sources"].get(ref.get("id")) or pack["sources"].get(ref.get("name")) or []
5947
+ documents = any(Path(f).suffix.lower() in DOCUMENT_SUFFIXES for f, _ in files)
5948
+ sid = src_ids.get(ref.get("id")) or src_ids.get(ref.get("name"))
5616
5949
  if sid:
5617
5950
  content["ref"] = {"id": sid, "name": SOURCES.get(sid)["name"]}
5618
- repoint_targets(doc, ws)
5951
+ repoint_targets(doc, ws, documents)
5619
5952
  new_work.append((key, doc))
5620
5953
  def place(body):
5621
5954
  body.setdefault("list", [])
@@ -6542,7 +6875,8 @@ class Queue:
6542
6875
  # a dynamic Source graded live is fetched at submit and kept here,
6543
6876
  # by item name, so the worker stays a pure dir-reader -- _materialise
6544
6877
  # 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.
6878
+ # run over a pinned response (#596) keeps that response's bytes here
6879
+ # too; a snapshot run keeps none and reads the Source's stored Calls.
6546
6880
  if "produced" not in cols:
6547
6881
  db.execute("ALTER TABLE queue ADD COLUMN produced TEXT")
6548
6882
  # The workspace a run belongs to (docs/workspaces.md): History is
@@ -6726,7 +7060,8 @@ class Queue:
6726
7060
  def produced(self, rid):
6727
7061
  """A live dynamic run's produced item bytes, by item name, or None for
6728
7062
  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
7063
+ values are the text the server fetched at submit, or the pinned
7064
+ response's (#596), kept so a start, a
6730
7065
  resume and a re-run grade the same bytes (docs/dynamic-sources.md)."""
6731
7066
  with self.lock, closing(sqlite3.connect(self.store.path)) as db:
6732
7067
  r = db.execute("SELECT produced FROM queue WHERE id = ?", (rid,)).fetchone()
@@ -7088,7 +7423,8 @@ class Queue:
7088
7423
  # A live dynamic run carries its items' bytes with the row, fetched at
7089
7424
  # submit (docs/dynamic-sources.md): they are written into the run dir
7090
7425
  # 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.
7426
+ # directory of files. A pinned run (#596) carries its response's bytes
7427
+ # the same way. A snapshot run has none and reads the Source.
7092
7428
  live = self.produced(run["id"])
7093
7429
  rundir = self.dir / run["id"]
7094
7430
  files_dir = rundir / "files"
@@ -7556,10 +7892,12 @@ def worker_destinations(run: dict):
7556
7892
  # (answer_key), each entry naming the connection whose key it takes (#445).
7557
7893
  # 17: an HTTP Request target has no `api` of its own; its job's flow step's
7558
7894
  # is the one its body is read by (#477).
7559
- PIPELINE_VERSION = 17
7895
+ # 18: a dynamic Source's content graded as saved pins one response, `pin:
7896
+ # {name, n}`, which a fetch, an edit or a Restore never moves (#596).
7897
+ PIPELINE_VERSION = 18
7560
7898
  # What a stored run may be: the current version, and the ones evals-core.ts's
7561
7899
  # 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)
7900
+ READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18)
7563
7901
  TARGET_CAP = 4
7564
7902
  # Target steps whose words the Prompt library does not record as a use: they
7565
7903
  # ask no model (evals-core.ts's STEP_TYPES.echo).
@@ -7990,6 +8328,10 @@ def run_problems(run):
7990
8328
  bad.append("a run over a Source needs its file list")
7991
8329
  elif not isinstance(files, list) or not files or not all(isinstance(f, str) for f in files):
7992
8330
  bad.append("a run over a Source needs its file list")
8331
+ pin = content.get("pin")
8332
+ if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
8333
+ and type(pin.get("n")) is int and pin["n"] >= 0):
8334
+ bad.append("a Source content's pin names a response as { name, n }")
7993
8335
  elif kind == "text":
7994
8336
  if not isinstance(content.get("text"), str) or not content["text"].strip():
7995
8337
  bad.append("a run over text needs some text")
@@ -9056,7 +9398,18 @@ class Handler(BaseHTTPRequestHandler):
9056
9398
  live = content.get("live")
9057
9399
  if live is None:
9058
9400
  live = str(cfg.get("live")).lower() in ("1", "true", "yes", "on") or not src.get("files")
9059
- if live:
9401
+ if live and content["files"]:
9402
+ # Live data over held items (#587): each response the
9403
+ # document names is fetched again under its own name,
9404
+ # so its case applies; a copy, an upload or an item
9405
+ # written by hand is graded as saved.
9406
+ produced, err = self._produce_endpoints(sid, entry, cfg, content["files"])
9407
+ if err:
9408
+ return self._json(err[0], {"error": err[1]})
9409
+ content["live"] = True
9410
+ content["revs"] = {name: "live:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
9411
+ for name, text in produced.items()}
9412
+ elif live:
9060
9413
  produced, name, err = self._produce_live(sid, entry, cfg)
9061
9414
  if err:
9062
9415
  return self._json(err[0], {"error": err[1]})
@@ -9070,6 +9423,20 @@ class Handler(BaseHTTPRequestHandler):
9070
9423
  produced[name].encode("utf-8")).hexdigest()}
9071
9424
  else:
9072
9425
  content["live"] = False
9426
+ pin = content.get("pin")
9427
+ if not content["live"] and isinstance(pin, dict):
9428
+ # Saved response pins one (#596): the run grades that
9429
+ # response's bytes, kept with the row as a live run's are,
9430
+ # whatever a fetch, an edit or a Restore made current since.
9431
+ body = SOURCES.pinned_body(sid, pin["name"], pin["n"])
9432
+ if body is None:
9433
+ return self._json(400, {"error": f"the response this run grades, {pin['name']}, "
9434
+ "was removed: pick another"})
9435
+ text = body.decode("utf-8", errors="replace")
9436
+ produced = {pin["name"]: text}
9437
+ loops = content.get("loops")
9438
+ content["files"] = [pin["name"]] * (loops if type(loops) is int and loops >= 1 else 1)
9439
+ content["revs"] = {pin["name"]: "pin:" + hashlib.sha256(body).hexdigest()}
9073
9440
  if produced is None:
9074
9441
  # Snapshot (or a non-dynamic Source): pin the Source's own files
9075
9442
  # at their current revision, the server's to say whatever the
@@ -9139,6 +9506,43 @@ class Handler(BaseHTTPRequestHandler):
9139
9506
  return self._json(201, {"run": QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None,
9140
9507
  produced=produced)})
9141
9508
 
9509
+ @staticmethod
9510
+ def _produce_endpoints(sid, entry, cfg, files):
9511
+ """Every item [files] names, for a live run over a Source that holds
9512
+ some (#587): a response -- a Call fetched from a request -- fetched
9513
+ now from the request it was, under its own name; any other item (a
9514
+ copy, an upload, one by hand) its saved bytes. Returns ({name: text},
9515
+ None), or (None, (code, sentence)) -- a fetch that fails refuses the
9516
+ submit, naming the response."""
9517
+ rows = {c["name"]: c for c in SOURCES.calls(sid) or []}
9518
+ out = {}
9519
+ for name in dict.fromkeys(files):
9520
+ call = rows.get(name) or {}
9521
+ request = call.get("request") if isinstance(call.get("request"), dict) else {}
9522
+ if call.get("origin") in ("snapshot", "run", "edited") and request.get("url"):
9523
+ # The request it was: a response whose Source has moved on
9524
+ # since is still asked what it was asked.
9525
+ result = entry["produce"]({**cfg, **{k: request[k] for k in ("url", "method")
9526
+ if isinstance(request.get(k), str)}})
9527
+ if result.get("refused"):
9528
+ return None, result["refused"]
9529
+ if result.get("content") is None:
9530
+ return None, (502, f"the live fetch of {name} produced nothing: "
9531
+ + (result.get("error") or "unknown"))
9532
+ kept, take_err = entry["take"](name, result["content"])
9533
+ if take_err:
9534
+ return None, take_err
9535
+ out[name] = kept.decode("utf-8")
9536
+ continue
9537
+ path = SOURCES.file_path(sid, name)
9538
+ if path is None or not path.is_file():
9539
+ return None, (400, f"{name!r} is not in that Source")
9540
+ try:
9541
+ out[name] = path.read_text("utf-8")
9542
+ except (OSError, UnicodeDecodeError):
9543
+ return None, (400, f"{name} is not text")
9544
+ return out, None
9545
+
9142
9546
  @staticmethod
9143
9547
  def _produce_live(sid, entry, cfg):
9144
9548
  """Produce a live dynamic Source's item now, for a run being queued:
@@ -9507,14 +9911,17 @@ class Handler(BaseHTTPRequestHandler):
9507
9911
  parts = path.split("/")
9508
9912
  if len(parts) == 3:
9509
9913
  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"])})
9914
+ return self._json(200, {"sources": sources, "cap": MAX_SOURCE})
9512
9915
  if len(parts) == 4:
9513
9916
  source = SOURCES.get(parts[3])
9514
9917
  return self._json(200, source) if source else self._send(404, b"not found", "text/plain")
9515
9918
  if len(parts) == 5 and parts[4] == "definition":
9516
9919
  got = SOURCES.definition(parts[3])
9517
9920
  return self._json(200, got) if got else self._send(404, b"not found", "text/plain")
9921
+ if len(parts) == 5 and parts[4] == "responses":
9922
+ held = SOURCES.responses(parts[3])
9923
+ return self._json(200, {"responses": held}) if held is not None \
9924
+ else self._send(404, b"not found", "text/plain")
9518
9925
  if len(parts) == 5 and parts[4] == "calls":
9519
9926
  calls = SOURCES.calls(parts[3])
9520
9927
  return self._json(200, {"calls": calls}) if calls is not None \
@@ -9590,7 +9997,17 @@ class Handler(BaseHTTPRequestHandler):
9590
9997
  if err:
9591
9998
  code, message = err
9592
9999
  return self._json(code, {"error": message})
9593
- return self._json(201, {"call": call})
10000
+ return self._json(201 if call["outcome"] == "added" else 200, {"call": call})
10001
+ if len(parts) == 6 and parts[4] == "responses" and parts[5] == "restore":
10002
+ payload = self._payload() or {}
10003
+ name = source_name(payload.get("name"))
10004
+ n, drop = payload.get("n"), payload.get("drop")
10005
+ if name is None or type(n) is not int or not (drop is None or type(drop) is int):
10006
+ return self._json(400, {"error": "a restore is { name, n, drop? }"})
10007
+ got, err = SOURCES.restore_response(parts[3], name, n, drop)
10008
+ if err:
10009
+ return self._json(err[0], {"error": err[1]})
10010
+ return self._json(200, {"call": got})
9594
10011
  if len(parts) == 6 and parts[4] == "calls" and parts[5] == "remove":
9595
10012
  names = (self._payload() or {}).get("names")
9596
10013
  if not isinstance(names, list) or not names:
@@ -9683,12 +10100,21 @@ class Handler(BaseHTTPRequestHandler):
9683
10100
  if len(body) != length:
9684
10101
  return self._json(400, {"error": "the body ended before Content-Length promised"})
9685
10102
  # A type that checks what it takes (a flow's records) has it first.
9686
- take = (SOURCES.type_of(sid) or {}).get("take")
10103
+ entry = SOURCES.type_of(sid) or {}
10104
+ take = entry.get("take")
9687
10105
  if take is not None:
9688
10106
  body, err = take(name, body)
9689
10107
  if err:
9690
10108
  code, message = err
9691
10109
  return self._json(code, {"error": message})
10110
+ # One a kind already holds by its key (a record of the same run's call,
10111
+ # edited or not) is not kept twice (#587).
10112
+ key = entry.get("key")
10113
+ value = key(body) if key is not None else None
10114
+ if value is not None:
10115
+ held = SOURCES.key_held(sid, key, value)
10116
+ if held is not None:
10117
+ return self._json(200, {"source": sid, "name": held, "unchanged": True})
9692
10118
  stored, err = SOURCES.add_file(sid, name, body)
9693
10119
  if err:
9694
10120
  code, message = err
@@ -9783,11 +10209,16 @@ class Handler(BaseHTTPRequestHandler):
9783
10209
  # key that comes is the connection's from then on, a rotated one included.
9784
10210
  # A connection carries its share set (`shares`, workspace slugs and the
9785
10211
  # sentinels; absent is All) from version 2: version 1 carried none, so an
9786
- # import reads its connections as All.
10212
+ # import reads its connections as All. Version 3 carries (Default)'s settings
10213
+ # (`connectionProfileDefault`, #603), which the profiles store keeps beside
10214
+ # its list; an import sets them only on a lab whose (Default) sets nothing.
9787
10215
 
9788
10216
  LAB_FORMAT = "evals-lab/lab"
9789
- LAB_VERSIONS = (1, 2)
9790
- LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "grader", "workspaces")
10217
+ LAB_VERSIONS = (1, 2, 3)
10218
+ LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "connectionProfileDefault",
10219
+ "grader", "workspaces")
10220
+ # What a Connection Profile holds that is not a setting, so (Default) holds none.
10221
+ PROFILE_META = ("id", "name", "connection", "fromProfile")
9791
10222
 
9792
10223
 
9793
10224
  def lab_export(only=None) -> dict:
@@ -9838,6 +10269,9 @@ def lab_export(only=None) -> dict:
9838
10269
  if (isinstance(body.get("grader"), str) and body["grader"] and body["grader"] not in nowhere
9839
10270
  and (not only or body["grader"] in kept)):
9840
10271
  out["grader"] = body["grader"]
10272
+ # (Default) answers on any connection, so every workspace's file has it.
10273
+ if isinstance(profiles.get("default"), dict) and profiles["default"]:
10274
+ out["connectionProfileDefault"] = profiles["default"]
9841
10275
  return out
9842
10276
 
9843
10277
 
@@ -9894,6 +10328,19 @@ def lab_problems(doc) -> str:
9894
10328
  why = _keyless(p, what)
9895
10329
  if why:
9896
10330
  return why
10331
+ if "connectionProfileDefault" in doc:
10332
+ d = doc["connectionProfileDefault"]
10333
+ if doc["version"] < 3:
10334
+ return f"a lab file of version {doc['version']} holds no connectionProfileDefault"
10335
+ if not isinstance(d, dict):
10336
+ return "the connection profile (Default) is an object of settings"
10337
+ bad = next((k for k, v in d.items() if k in PROFILE_META or not isinstance(v, str)), None)
10338
+ if bad:
10339
+ return f"the connection profile (Default)'s {bad} is not a setting"
10340
+ # A profile's key is its connection's, and (Default) names none.
10341
+ held = next((k for k, v in d.items() if LOOKS_LIKE_A_KEY.match(k) and v), None)
10342
+ if held:
10343
+ return f"the connection profile (Default) holds a {held}; a lab file holds no key"
9897
10344
  if "grader" in doc and not isinstance(doc["grader"], str):
9898
10345
  return "the grader is not a connection's id"
9899
10346
  seen = set()
@@ -9992,6 +10439,10 @@ def lab_import(doc, keys) -> tuple:
9992
10439
  profs.append(dict(p))
9993
10440
  pchanged = True
9994
10441
  lines.append(f"connection profile {p['name']} added")
10442
+ if doc.get("connectionProfileDefault") and not pbody.get("default"):
10443
+ pbody["default"] = dict(doc["connectionProfileDefault"])
10444
+ pchanged = True
10445
+ lines.append("connection profile (Default) set")
9995
10446
  if doc.get("grader") and not body.get("grader"):
9996
10447
  body["grader"] = doc["grader"]
9997
10448
  changed = True