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