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.
- package/CHANGELOG.md +39 -0
- package/README.md +2 -2
- package/bin/run.js +4 -2
- package/lab/VERSION +1 -1
- package/lab/demo/CREDITS.md +14 -0
- package/lab/demo/datasets/demo-1.json +2 -2
- package/lab/demo/datasets/demo-2.json +2 -2
- package/lab/demo/datasets/documents-type.json +79 -0
- package/lab/demo/datasets/documents-valid.json +117 -0
- package/lab/demo/datasets/sentiment.json +2 -2
- package/lab/demo/datasets/weather-alerts.json +2 -2
- package/lab/demo/datasets/wordings.json +1 -1
- package/lab/demo/manifest.json +3 -3
- package/lab/demo/pipelines/demo-1.json +4 -4
- package/lab/demo/pipelines/demo-2.json +4 -4
- package/lab/demo/pipelines/documents.json +102 -0
- package/lab/demo/pipelines/sentiment.json +4 -4
- package/lab/demo/pipelines/weather-alerts.json +9 -5
- package/lab/demo/pipelines/wordings.json +3 -3
- package/lab/demo/sources/Demo 1/source.json +1 -1
- package/lab/demo/sources/Demo 2/source.json +1 -1
- package/lab/demo/sources/documents/invoice.pdf +65 -0
- package/lab/demo/sources/documents/letter.pdf +63 -0
- package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
- package/lab/demo/sources/documents/receipt.pdf +62 -0
- package/lab/demo/sources/documents/report.pdf +59 -0
- package/lab/demo/sources/documents/source.json +3 -0
- package/lab/demo/sources/documents/truncated.pdf +19 -0
- package/lab/demo/sources/sentiment/source.json +1 -1
- package/lab/demo/sources/weather-alerts/source.json +1 -1
- package/lab/evals-core.mjs +217 -48
- package/lab/flows/record.mjs +5 -4
- package/lab/kinds/list.mjs +72 -21
- package/lab/run-evals.js +3 -1
- package/lab/server.py +536 -85
- package/lab/web/dist/assets/gallery-qrf0ZGXK.js +5 -0
- package/lab/web/dist/assets/main-D6xARg2A.js +16 -0
- package/lab/web/dist/assets/main-xVU6J7Gi.css +1 -0
- package/lab/web/dist/assets/tokens-C8dqXDHN.js +62 -0
- package/lab/web/dist/assets/tokens-tQ7zTPkd.css +1 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-DpaqOg9Z.js +0 -5
- package/lab/web/dist/assets/main-BSw-jyYD.css +0 -1
- package/lab/web/dist/assets/main-CrB8wPZn.js +0 -17
- package/lab/web/dist/assets/tokens-CrjDMCbz.js +0 -60
- 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": "
|
|
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": "
|
|
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
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
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
|
-
"""
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
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
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
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.
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
Returns ({name, origin, bytes}
|
|
2643
|
-
|
|
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
|
|
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`, ...)
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
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
|
-
|
|
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, ?,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|