evals-lab 0.14.0 → 0.16.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 +97 -0
- package/README.md +35 -14
- package/bin/evals-lab.js +8 -1
- package/bin/presets.js +234 -0
- package/lab/VERSION +1 -1
- package/lab/demo/manifest.json +2 -2
- package/lab/demo/pipelines/demo-1.json +105 -103
- package/lab/demo/pipelines/demo-2.json +105 -103
- package/lab/demo/pipelines/documents.json +54 -52
- package/lab/demo/pipelines/sentiment.json +30 -30
- package/lab/demo/pipelines/weather-alerts.json +35 -35
- package/lab/demo/pipelines/wordings.json +33 -36
- package/lab/evals-core.mjs +1053 -353
- package/lab/kinds/list.mjs +328 -224
- package/lab/metrics/builtin.mjs +24 -24
- package/lab/run-evals.js +34 -56
- package/lab/server.py +478 -206
- package/lab/web/dist/assets/gallery-ICJNZQIZ.js +5 -0
- package/lab/web/dist/assets/gallery-bG2vNzKT.css +1 -0
- package/lab/web/dist/assets/main-BOkQWswU.css +1 -0
- package/lab/web/dist/assets/main-rJDsPFWT.js +18 -0
- package/lab/web/dist/assets/tokens-C5ist-xG.js +64 -0
- package/lab/web/dist/assets/tokens-CuK6hUMK.css +1 -0
- package/lab/web/dist/gallery.html +4 -4
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/presets.json +0 -62
- package/lab/web/dist/assets/gallery-B_-TH0F-.css +0 -1
- package/lab/web/dist/assets/gallery-qrf0ZGXK.js +0 -5
- package/lab/web/dist/assets/main-D6xARg2A.js +0 -16
- package/lab/web/dist/assets/main-xVU6J7Gi.css +0 -1
- package/lab/web/dist/assets/tokens-C8dqXDHN.js +0 -62
- package/lab/web/dist/assets/tokens-tQ7zTPkd.css +0 -1
package/lab/server.py
CHANGED
|
@@ -89,12 +89,6 @@ DISPLAYABLE = (".jpg", ".jpeg", ".png")
|
|
|
89
89
|
# is what issue #270 turned out to be.
|
|
90
90
|
UNDISPLAYABLE = (".heic", ".heif", ".dng", ".tif", ".tiff")
|
|
91
91
|
|
|
92
|
-
# The llama.cpp presets the Setup tab offers as Apply preset…: measured
|
|
93
|
-
# decoding settings, Setup's data and no dataset's. Read by the page (served
|
|
94
|
-
# at /api/presets) and by evals-check.js's Decoding.kt comparison; absent, the
|
|
95
|
-
# tab just has no presets to offer, so it is not fatal to start.
|
|
96
|
-
PRESETS = HERE / "presets.json" if (HERE / "presets.json").is_file() else None
|
|
97
|
-
|
|
98
92
|
# The page, built from web/ (React + Vite, #24 and #27's page PR): the image's
|
|
99
93
|
# web stage writes it here, as `npm run build` does in a checkout. Required,
|
|
100
94
|
# like the dataset: without it there is no page. WEB_DIST names another
|
|
@@ -2611,7 +2605,7 @@ class Sources:
|
|
|
2611
2605
|
provider, json.dumps(request) if request else None, status, error, file))
|
|
2612
2606
|
|
|
2613
2607
|
def snapshot(self, sid):
|
|
2614
|
-
"""
|
|
2608
|
+
"""Get response (#587): produce the Source's Call through the provider,
|
|
2615
2609
|
the `take` hook refusing a token and redacting, and keep it as a
|
|
2616
2610
|
version of the response its name says (#596). A response already held
|
|
2617
2611
|
gains the version and keeps its row, whatever was done to it -- an edit
|
|
@@ -2661,14 +2655,19 @@ class Sources:
|
|
|
2661
2655
|
return {"name": stored, "outcome": "added", "provider": provider, "request": request,
|
|
2662
2656
|
"status": status, "bytes": len(kept)}, None
|
|
2663
2657
|
if path.read_bytes() == kept:
|
|
2658
|
+
# Status is the newest fetch's, though Updated stays when the
|
|
2659
|
+
# current response came.
|
|
2660
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2661
|
+
db.execute("UPDATE source_calls SET status = ?, error = NULL WHERE source = ? AND name = ?",
|
|
2662
|
+
(status, sid, name))
|
|
2664
2663
|
return {"name": name, "outcome": "unchanged", "status": status}, None
|
|
2665
2664
|
added, err = self._respond(sid, name, kept, "fetched", status)
|
|
2666
2665
|
if err:
|
|
2667
2666
|
return None, err
|
|
2668
2667
|
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 = ? "
|
|
2668
|
+
db.execute("UPDATE source_calls SET request = ?, provider = ?, status = ?, error = NULL, file = ? "
|
|
2670
2669
|
"WHERE source = ? AND name = ?",
|
|
2671
|
-
(json.dumps(request) if request else None, provider, name, sid, name))
|
|
2670
|
+
(json.dumps(request) if request else None, provider, status, name, sid, name))
|
|
2672
2671
|
return {"name": name, "outcome": "fetched", "provider": provider, "request": request,
|
|
2673
2672
|
"status": status, "bytes": len(kept), **added}, None
|
|
2674
2673
|
|
|
@@ -3700,23 +3699,89 @@ def upgrade_body(body):
|
|
|
3700
3699
|
|
|
3701
3700
|
|
|
3702
3701
|
# The filter-set body's version: evals-core.ts's FILTER_SET_VERSION. Version 3
|
|
3703
|
-
# (#432)
|
|
3704
|
-
#
|
|
3705
|
-
#
|
|
3706
|
-
#
|
|
3707
|
-
#
|
|
3708
|
-
|
|
3709
|
-
|
|
3702
|
+
# (#432) dropped the `stage` version 2 (#405) gave it. Version 4 (#615) has
|
|
3703
|
+
# seven verbs that run wherever they sit, around a Response Format a set may
|
|
3704
|
+
# straddle (its `format`). A filter set is a Library row the lab stores and
|
|
3705
|
+
# versions like an eval group (#336, FilterSets below); this and its twin are
|
|
3706
|
+
# the body it keeps, held to evals-core.ts by proxy-check.py.
|
|
3707
|
+
FILTER_SET_BODY_VERSION = 4
|
|
3708
|
+
# A version-3 filter's types that read the reply's text; the rest read items.
|
|
3709
|
+
V3_TEXT_TYPES = ("rewrite-text", "reject-text")
|
|
3710
|
+
|
|
3711
|
+
|
|
3712
|
+
def filter_v4(f):
|
|
3713
|
+
"""A version-3 filter as the version-4 verb that reads a reply exactly as
|
|
3714
|
+
it did: kinds/list.ts's filterV4, in Python. A filter of no version-3
|
|
3715
|
+
type comes back as it was. Pure."""
|
|
3716
|
+
if not isinstance(f, dict):
|
|
3717
|
+
return f
|
|
3718
|
+
op = f.get("op") if isinstance(f.get("op"), dict) else {}
|
|
3719
|
+
|
|
3720
|
+
def pick(**keys):
|
|
3721
|
+
# A setting the old op lacked stays absent, as JavaScript's undefined
|
|
3722
|
+
# does in JSON; one it held as null stays null.
|
|
3723
|
+
return {k: op[v] for k, v in keys.items() if v in op}
|
|
3724
|
+
|
|
3725
|
+
def as_(kind, new_op=None):
|
|
3726
|
+
out = {k: v for k, v in f.items() if k != "op"}
|
|
3727
|
+
out["type"] = kind
|
|
3728
|
+
if new_op is not None:
|
|
3729
|
+
out["op"] = new_op
|
|
3730
|
+
if f.get("type") in V3_TEXT_TYPES and isinstance(f.get("tests"), list):
|
|
3731
|
+
out["tests"] = [{**t, "separator": "none"} if isinstance(t, dict) else t for t in f["tests"]]
|
|
3732
|
+
return out
|
|
3733
|
+
|
|
3734
|
+
replacement = op.get("replacement")
|
|
3735
|
+
replacement = "" if replacement is None else replacement
|
|
3736
|
+
t = f.get("type")
|
|
3737
|
+
if t == "rewrite-text":
|
|
3738
|
+
k = op.get("kind")
|
|
3739
|
+
if k in ("thinking", "preamble", "fences"):
|
|
3740
|
+
return as_("remove", {"kind": k})
|
|
3741
|
+
if k == "trim":
|
|
3742
|
+
return as_("trim", {"bullets": False})
|
|
3743
|
+
if k == "literal":
|
|
3744
|
+
return as_("replace", {"kind": "text", **pick(find="find"), "replacement": replacement})
|
|
3745
|
+
if k == "replace":
|
|
3746
|
+
return as_("replace", {"kind": "pattern", **pick(pattern="pattern"), "replacement": replacement})
|
|
3747
|
+
if k == "case":
|
|
3748
|
+
return as_("change-case", pick(mode="mode"))
|
|
3749
|
+
if k == "first":
|
|
3750
|
+
return as_("keep-first", pick(n="n", unit="unit"))
|
|
3751
|
+
if k == "extract":
|
|
3752
|
+
return as_("extract", pick(pattern="pattern"))
|
|
3753
|
+
return f
|
|
3754
|
+
if t in ("reject-text", "reject-reply"):
|
|
3755
|
+
return as_("reject")
|
|
3756
|
+
if t == "drop-item":
|
|
3757
|
+
return as_("remove", {"kind": "items"})
|
|
3758
|
+
if t == "rewrite-item":
|
|
3759
|
+
return as_("change-case", pick(mode="mode")) if op.get("kind") == "case" else as_("trim", {"bullets": True})
|
|
3760
|
+
if t == "keep-first":
|
|
3761
|
+
return as_("keep-first", {**pick(n="n"), "unit": "items"})
|
|
3762
|
+
return f
|
|
3710
3763
|
|
|
3711
3764
|
|
|
3712
3765
|
def upgrade_filter_set_body(body):
|
|
3713
|
-
"""An earlier filter-set body as today's (version
|
|
3714
|
-
upgradeFilterSetBody, in Python. A version-1
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3766
|
+
"""An earlier filter-set body as today's (version 4): evals-core.ts's
|
|
3767
|
+
upgradeFilterSetBody, in Python. A version-1 to -3 body loses any `stage`,
|
|
3768
|
+
each filter becomes its verb (filter_v4), and one that held both text and
|
|
3769
|
+
item filters puts the text ones first, then its `format` marker, then the
|
|
3770
|
+
item ones. A version-4 body, and anything that is not a body, come back as
|
|
3771
|
+
they are. Pure."""
|
|
3772
|
+
if not isinstance(body, dict) or body.get("version") not in (1, 2, 3):
|
|
3773
|
+
return body
|
|
3774
|
+
rest = {k: v for k, v in body.items() if k != "stage"}
|
|
3775
|
+
filters = rest.get("filters")
|
|
3776
|
+
if not isinstance(filters, list):
|
|
3777
|
+
return {**rest, "version": FILTER_SET_BODY_VERSION}
|
|
3778
|
+
is_text = [isinstance(f, dict) and f.get("type") in V3_TEXT_TYPES for f in filters]
|
|
3779
|
+
text = [f for f, x in zip(filters, is_text) if x]
|
|
3780
|
+
items = [f for f, x in zip(filters, is_text) if not x]
|
|
3781
|
+
out = {**rest, "version": FILTER_SET_BODY_VERSION, "filters": [filter_v4(f) for f in text + items]}
|
|
3782
|
+
if text and items:
|
|
3783
|
+
out["format"] = len(text)
|
|
3784
|
+
return out
|
|
3720
3785
|
|
|
3721
3786
|
|
|
3722
3787
|
def body_prompt(body):
|
|
@@ -3808,11 +3873,11 @@ def response_pins(db, sid, name) -> set:
|
|
|
3808
3873
|
except (ValueError, TypeError, AttributeError):
|
|
3809
3874
|
continue
|
|
3810
3875
|
for w in listed if isinstance(listed, list) else []:
|
|
3811
|
-
c
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3876
|
+
for _step, c, _at in item_contents(w.get("work")) if isinstance(w, dict) else []:
|
|
3877
|
+
pin = c.get("pin")
|
|
3878
|
+
if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
|
|
3879
|
+
and pin.get("name") == name and type(pin.get("n")) is int):
|
|
3880
|
+
out.add(pin["n"] or 1)
|
|
3816
3881
|
return out
|
|
3817
3882
|
|
|
3818
3883
|
|
|
@@ -3862,6 +3927,8 @@ def free_name(base: str, taken: set, op: str) -> str:
|
|
|
3862
3927
|
# are the body's; the per-filter shape is the core's to judge, as a case's
|
|
3863
3928
|
# metrics are (dataset_problem). The suffixing is the generic one.
|
|
3864
3929
|
FILTER_SET_FIELDS = ("version", "filters")
|
|
3930
|
+
# A body may also say where a job's Response Format reads among its filters.
|
|
3931
|
+
FILTER_SET_OPTIONAL = ("format",)
|
|
3865
3932
|
FILTER_SET_NAME_MAX = 80
|
|
3866
3933
|
unique_filter_set_name = unique_dataset_name
|
|
3867
3934
|
|
|
@@ -3872,7 +3939,7 @@ def filter_set_problem(body) -> str:
|
|
|
3872
3939
|
if not isinstance(body, dict):
|
|
3873
3940
|
return "a filter set's body is a JSON object"
|
|
3874
3941
|
for k in body:
|
|
3875
|
-
if k not in FILTER_SET_FIELDS:
|
|
3942
|
+
if k not in FILTER_SET_FIELDS + FILTER_SET_OPTIONAL:
|
|
3876
3943
|
return f"a filter set's body has \"{k}\", which is not a filter-set field"
|
|
3877
3944
|
for k in FILTER_SET_FIELDS:
|
|
3878
3945
|
if k not in body:
|
|
@@ -3881,6 +3948,9 @@ def filter_set_problem(body) -> str:
|
|
|
3881
3948
|
return f"a filter set's body is version {FILTER_SET_BODY_VERSION}"
|
|
3882
3949
|
if not isinstance(body["filters"], list) or not all(isinstance(f, dict) for f in body["filters"]):
|
|
3883
3950
|
return "filters has to be a list of filters"
|
|
3951
|
+
at = body.get("format")
|
|
3952
|
+
if at is not None and (type(at) is not int or not 0 <= at <= len(body["filters"])):
|
|
3953
|
+
return "Response Format has to sit among its filters"
|
|
3884
3954
|
return ""
|
|
3885
3955
|
|
|
3886
3956
|
|
|
@@ -3902,6 +3972,17 @@ def filter_set_links(doc) -> list:
|
|
|
3902
3972
|
Runs wires the step and migrates the inline rules in #339; this is the one
|
|
3903
3973
|
reader of where the links sit, for the store (#336) to resolve and keep."""
|
|
3904
3974
|
out = []
|
|
3975
|
+
# Version 20: filter sets live on the Filter steps of the flat step list
|
|
3976
|
+
# (evals-core.ts); version 19 and older held them on a job's Responses
|
|
3977
|
+
# steps.
|
|
3978
|
+
steps = doc.get("steps") if isinstance(doc, dict) else None
|
|
3979
|
+
if isinstance(steps, list) and any(isinstance(s, dict) and s.get("type") in ("getItems", "request") for s in steps):
|
|
3980
|
+
for st in steps:
|
|
3981
|
+
links = st.get("filterSets") if isinstance(st, dict) else None
|
|
3982
|
+
for link in links if isinstance(links, list) else []:
|
|
3983
|
+
if isinstance(link, dict):
|
|
3984
|
+
out.append(link)
|
|
3985
|
+
return out
|
|
3905
3986
|
jobs = doc.get("jobs") if isinstance(doc, dict) else None
|
|
3906
3987
|
for job in jobs if isinstance(jobs, list) else []:
|
|
3907
3988
|
steps = job.get("steps") if isinstance(job, dict) else None
|
|
@@ -4067,6 +4148,24 @@ class Prompts:
|
|
|
4067
4148
|
"ORDER BY at DESC, run_id, job", (r["id"],))]
|
|
4068
4149
|
return out
|
|
4069
4150
|
|
|
4151
|
+
def used_by(self, run_ids) -> dict:
|
|
4152
|
+
"""Each run's prompt uses, by run id: the target, the job, and the
|
|
4153
|
+
prompt and version its words were recorded as. A trashed prompt's
|
|
4154
|
+
uses are left out with it."""
|
|
4155
|
+
out = {}
|
|
4156
|
+
ids = list(run_ids)
|
|
4157
|
+
with self.store.lock, self._connect() as db:
|
|
4158
|
+
for k in range(0, len(ids), 500):
|
|
4159
|
+
chunk = ids[k:k + 500]
|
|
4160
|
+
for u in db.execute(
|
|
4161
|
+
"SELECT u.run_id, u.scenario_id, u.job, u.version, p.id, p.name FROM prompt_uses u "
|
|
4162
|
+
"JOIN prompts p ON p.id = u.prompt_id WHERE p.trash IS NULL AND p.workspace = ? "
|
|
4163
|
+
f"AND u.run_id IN ({', '.join('?' * len(chunk))}) ORDER BY u.job",
|
|
4164
|
+
(workspace_of(self.store), *chunk)):
|
|
4165
|
+
out.setdefault(u["run_id"], []).append(
|
|
4166
|
+
{"target": u["scenario_id"], "job": u["job"], "id": u["id"], "name": u["name"], "version": u["version"]})
|
|
4167
|
+
return out
|
|
4168
|
+
|
|
4070
4169
|
def list(self) -> list:
|
|
4071
4170
|
with self.store.lock, self._connect() as db:
|
|
4072
4171
|
return [self._summary(db, r) for r in db.execute(
|
|
@@ -4144,9 +4243,13 @@ class Prompts:
|
|
|
4144
4243
|
prompt -- the version reading its words, or a new version if they
|
|
4145
4244
|
were edited after the pick; any other cell links to the prompt
|
|
4146
4245
|
already reading its words, or starts a new, unnamed one. A use is
|
|
4147
|
-
recorded once: a second call for the same run changes nothing.
|
|
4246
|
+
recorded once: a second call for the same run changes nothing.
|
|
4247
|
+
Returns each recorded cell's prompt, {target, job, prompt: {id,
|
|
4248
|
+
name}}: the page links a cell to it, so the next edit of typed words
|
|
4249
|
+
is that prompt's next version rather than another unnamed one."""
|
|
4148
4250
|
# The caller's connection: a Row still reads by index, as it does.
|
|
4149
4251
|
db.row_factory = sqlite3.Row
|
|
4252
|
+
recorded = []
|
|
4150
4253
|
for i, sc, k, cell in cells_of(run):
|
|
4151
4254
|
sid, sname = scenario_ref(sc, i)
|
|
4152
4255
|
# Echo's words are no wording under test: the item is its reply,
|
|
@@ -4169,6 +4272,9 @@ class Prompts:
|
|
|
4169
4272
|
db.execute("INSERT OR IGNORE INTO prompt_uses (prompt_id, version, run_id, scenario_id, "
|
|
4170
4273
|
"scenario_name, job, at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
4171
4274
|
(pid, version, rid, sid, sname, k, at))
|
|
4275
|
+
name = db.execute("SELECT name FROM prompts WHERE id = ?", (pid,)).fetchone()[0]
|
|
4276
|
+
recorded.append({"target": sid, "job": k, "prompt": {"id": pid, "name": name}})
|
|
4277
|
+
return recorded
|
|
4172
4278
|
|
|
4173
4279
|
def backfill(self, runs):
|
|
4174
4280
|
"""The runs from before the library, read into it once. [runs] is
|
|
@@ -5533,23 +5639,38 @@ def pack_connection(ws, documents=False):
|
|
|
5533
5639
|
|
|
5534
5640
|
|
|
5535
5641
|
def repoint_targets(doc: dict, ws, documents=False) -> None:
|
|
5536
|
-
"""A pack pipeline's
|
|
5537
|
-
pointed at the one pack_connection picks, its profile
|
|
5538
|
-
|
|
5539
|
-
|
|
5642
|
+
"""A pack pipeline's Variants or Request cells that name a connection this
|
|
5643
|
+
lab does not have, pointed at the one pack_connection picks, its profile
|
|
5644
|
+
dropped with it. Version 20 keeps the connection on each Variant and on the
|
|
5645
|
+
Request steps' cells (and a Variant's overrides), so each is repointed."""
|
|
5646
|
+
if not isinstance(doc, dict) or STORE is None:
|
|
5540
5647
|
return
|
|
5541
5648
|
have = {c.get("id") for c in ((STORE.all().get("promptlab.connections") or {}).get("body") or {}).get("list") or []
|
|
5542
5649
|
if isinstance(c, dict)}
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
pick = pick or pack_connection(ws, documents)
|
|
5549
|
-
if pick is None:
|
|
5650
|
+
state = {"pick": None, "none": False}
|
|
5651
|
+
|
|
5652
|
+
def repoint(o):
|
|
5653
|
+
ref = o.get("connection") if isinstance(o, dict) else None
|
|
5654
|
+
if not isinstance(ref, dict) or ref.get("id") in have or state["none"]:
|
|
5550
5655
|
return
|
|
5551
|
-
|
|
5552
|
-
|
|
5656
|
+
if state["pick"] is None:
|
|
5657
|
+
state["pick"] = pack_connection(ws, documents)
|
|
5658
|
+
if state["pick"] is None:
|
|
5659
|
+
state["none"] = True
|
|
5660
|
+
return
|
|
5661
|
+
pick = state["pick"]
|
|
5662
|
+
o["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
|
|
5663
|
+
if "profile" in o:
|
|
5664
|
+
o["profile"] = None
|
|
5665
|
+
|
|
5666
|
+
for v in doc.get("variants") or []:
|
|
5667
|
+
if isinstance(v, dict):
|
|
5668
|
+
repoint(v)
|
|
5669
|
+
for ov in (v.get("overrides") or {}).values():
|
|
5670
|
+
repoint(ov)
|
|
5671
|
+
for s in doc.get("steps") or []:
|
|
5672
|
+
if isinstance(s, dict) and s.get("type") == "request":
|
|
5673
|
+
repoint(s.get("cell"))
|
|
5553
5674
|
|
|
5554
5675
|
|
|
5555
5676
|
def pack_requires_problem(manifest: dict, plugins: set) -> str:
|
|
@@ -5645,7 +5766,8 @@ def read_pack(data: bytes):
|
|
|
5645
5766
|
if why:
|
|
5646
5767
|
return None, why
|
|
5647
5768
|
if (not isinstance(doc, dict) or doc.get("version") not in READABLE_VERSIONS
|
|
5648
|
-
or not (isinstance(doc.get("
|
|
5769
|
+
or not (isinstance(doc.get("steps"), list) or isinstance(doc.get("jobs"), list)
|
|
5770
|
+
or isinstance(doc.get("chains"), list))):
|
|
5649
5771
|
return None, f"{name} is not a pipeline document the lab reads"
|
|
5650
5772
|
pack["pipelines"].append((parts[1][:-5], doc))
|
|
5651
5773
|
elif len(parts) == 3 and parts[0] == "sources" and parts[2] == PACK_SOURCE_META:
|
|
@@ -5809,11 +5931,11 @@ class Packs:
|
|
|
5809
5931
|
return out
|
|
5810
5932
|
|
|
5811
5933
|
def presets(self) -> list:
|
|
5812
|
-
"""Every installed pack's Setup presets, each marked with its pack."""
|
|
5934
|
+
"""Every installed pack's Setup presets, each marked with its pack's id and name."""
|
|
5813
5935
|
with self.store.lock, self._connect() as db:
|
|
5814
|
-
rows = db.execute("SELECT id, presets FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
|
|
5936
|
+
rows = db.execute("SELECT id, name, presets FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
|
|
5815
5937
|
(workspace_of(self.store),)).fetchall()
|
|
5816
|
-
return [{**p, "pack": r["id"]} for r in rows for p in json.loads(r["presets"])]
|
|
5938
|
+
return [{**p, "pack": r["id"], "packName": r["name"]} for r in rows for p in json.loads(r["presets"])]
|
|
5817
5939
|
|
|
5818
5940
|
# The page's documents are the server's to write here too, through the
|
|
5819
5941
|
# docs table's own arbitration: a browser holding an older copy is told
|
|
@@ -6721,12 +6843,20 @@ def key_var(profile_id: str, slug=None) -> str:
|
|
|
6721
6843
|
|
|
6722
6844
|
|
|
6723
6845
|
def run_items(run):
|
|
6724
|
-
"""A run's items:
|
|
6725
|
-
or
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
|
|
6729
|
-
|
|
6846
|
+
"""A run's items (evals-core.ts runItemsOf): each Get items step's in turn,
|
|
6847
|
+
a Source's file list in order and with its repeats or one inline text; a
|
|
6848
|
+
run where none adds one goes over the bare item, its prompt alone."""
|
|
6849
|
+
out, bare = [], False
|
|
6850
|
+
for _step, content, _at in item_contents(run):
|
|
6851
|
+
kind = content.get("type")
|
|
6852
|
+
if kind == "source":
|
|
6853
|
+
out.extend(content.get("files") or [])
|
|
6854
|
+
elif kind == "text":
|
|
6855
|
+
if isinstance(content.get("text"), str) and content["text"].strip():
|
|
6856
|
+
out.append(None)
|
|
6857
|
+
elif kind == "prompt":
|
|
6858
|
+
bare = True
|
|
6859
|
+
return out or ([None] if bare else [])
|
|
6730
6860
|
|
|
6731
6861
|
|
|
6732
6862
|
def worker_refusal(code, stderr, env):
|
|
@@ -6941,18 +7071,23 @@ class Queue:
|
|
|
6941
7071
|
row = self._row(db.execute(sql, (rid, ws) if ws else (rid,)).fetchone())
|
|
6942
7072
|
return row if self._readable(row) else None
|
|
6943
7073
|
|
|
6944
|
-
def list(self, limit=RUNS_PAGE, before=None, before_id=None, full=False):
|
|
7074
|
+
def list(self, limit=RUNS_PAGE, before=None, before_id=None, full=False, pipeline=None):
|
|
6945
7075
|
"""Runs, newest first, and whether more follow. `before` and
|
|
6946
7076
|
`before_id` are the `submittedAt` and id of the last run on the page
|
|
6947
7077
|
before, so History can page through them the way it pages the runs
|
|
6948
7078
|
store. `submittedAt` is to the second, so two runs can share one; the
|
|
6949
7079
|
id breaks the tie, or a page ending between them would skip the
|
|
6950
|
-
second (#238). `before` alone stops at the second.
|
|
7080
|
+
second (#238). `before` alone stops at the second. `pipeline` keeps
|
|
7081
|
+
the runs of one pipeline, by its document's id: History's Overview
|
|
7082
|
+
(#642) reads a window of one pipeline's runs, which paging every run
|
|
7083
|
+
would find slowly where that pipeline runs rarely. Each row is
|
|
6951
7084
|
brief_row's unless `full` asks for the whole of it."""
|
|
6952
7085
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
6953
7086
|
rows = self._all(db, workspace_of(self.store))
|
|
6954
7087
|
key = lambda r: (r["submittedAt"], r["id"])
|
|
6955
7088
|
rows = [r for r in rows if before is None or key(r) < (before, before_id or "")]
|
|
7089
|
+
if pipeline is not None:
|
|
7090
|
+
rows = [r for r in rows if (r["snapshot"] or {}).get("id") == pipeline]
|
|
6956
7091
|
rows.sort(key=key, reverse=True)
|
|
6957
7092
|
page = rows[:limit]
|
|
6958
7093
|
return (page if full else [brief_row(r) for r in page]), len(rows) > limit
|
|
@@ -6964,7 +7099,8 @@ class Queue:
|
|
|
6964
7099
|
|
|
6965
7100
|
# ---- submit ---------------------------------------------------------
|
|
6966
7101
|
|
|
6967
|
-
def submit(self, run: dict, dataset=None, rerun_of=None, groups=None, filter_sets=None, produced=None
|
|
7102
|
+
def submit(self, run: dict, dataset=None, rerun_of=None, groups=None, filter_sets=None, produced=None,
|
|
7103
|
+
recorded=None):
|
|
6968
7104
|
"""
|
|
6969
7105
|
A new queued run. `run` is the run document (docs/pipeline-model.md
|
|
6970
7106
|
§5): the pipeline, the profiles it resolved to without their keys, its
|
|
@@ -6977,7 +7113,8 @@ class Queue:
|
|
|
6977
7113
|
run a re-run was queued from. Returns the row. Its items are that list,
|
|
6978
7114
|
or the one inline text, each through every scenario -- so the total is
|
|
6979
7115
|
the list's length, repeats and all, the same count the runner and the
|
|
6980
|
-
page make.
|
|
7116
|
+
page make. A `recorded` list is given each cell's prompt, as
|
|
7117
|
+
Prompts.record returns them.
|
|
6981
7118
|
"""
|
|
6982
7119
|
rid = secrets.token_hex(6)
|
|
6983
7120
|
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
@@ -6987,7 +7124,7 @@ class Queue:
|
|
|
6987
7124
|
"snapshot, results, progress, totals, dataset, rerun_of, groups, filter_sets, produced, workspace) "
|
|
6988
7125
|
"VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
6989
7126
|
(rid, "queued", now, json.dumps(run), "[]",
|
|
6990
|
-
json.dumps({"current": None, "n": 0, "total": total}),
|
|
7127
|
+
json.dumps({"current": None, "at": None, "n": 0, "total": total}),
|
|
6991
7128
|
json.dumps({"ran": 0, "passed": 0, "found": 0, "of": 0}),
|
|
6992
7129
|
None if dataset is None else json.dumps(dataset), rerun_of,
|
|
6993
7130
|
None if groups is None else json.dumps(groups),
|
|
@@ -6997,7 +7134,9 @@ class Queue:
|
|
|
6997
7134
|
# Its prompts' uses, in the same transaction: a run is in the
|
|
6998
7135
|
# library the moment it is queued, or not queued at all.
|
|
6999
7136
|
if self.prompts is not None:
|
|
7000
|
-
self.prompts.record(db, rid, run, now)
|
|
7137
|
+
links = self.prompts.record(db, rid, run, now)
|
|
7138
|
+
if recorded is not None:
|
|
7139
|
+
recorded.extend(links)
|
|
7001
7140
|
# Read back under the lock the worker dequeues under, so the
|
|
7002
7141
|
# answer is the run as it was queued: an idle worker can take it
|
|
7003
7142
|
# the moment the lock is let go.
|
|
@@ -7237,9 +7376,10 @@ class Queue:
|
|
|
7237
7376
|
if run["status"] in ("queued", "running"):
|
|
7238
7377
|
return None, (409, "a run still in progress cannot be re-run")
|
|
7239
7378
|
snap = run["snapshot"]
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7379
|
+
for _step, content, _at in item_contents(snap):
|
|
7380
|
+
_, err = self._pinned_files(content)
|
|
7381
|
+
if err:
|
|
7382
|
+
return None, (409, err)
|
|
7243
7383
|
# The group bodies the original kept: a re-run grades with exactly
|
|
7244
7384
|
# them, whatever the groups or the pins read now; its filter-set
|
|
7245
7385
|
# bodies travel the same way, so it cleans and gates as the original did.
|
|
@@ -7430,29 +7570,34 @@ class Queue:
|
|
|
7430
7570
|
files_dir = rundir / "files"
|
|
7431
7571
|
shutil.rmtree(rundir, ignore_errors=True)
|
|
7432
7572
|
files_dir.mkdir(parents=True)
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7573
|
+
# Each Get items step's files under its own folder (item_contents): a
|
|
7574
|
+
# content produced at submit from the bytes kept with the row, any
|
|
7575
|
+
# other from its Source.
|
|
7576
|
+
for _step, content, at in item_contents(snap):
|
|
7577
|
+
folder = files_dir / at if at else files_dir
|
|
7578
|
+
folder.mkdir(exist_ok=True)
|
|
7579
|
+
if live is not None and (content.get("live") is True or isinstance(content.get("pin"), dict)):
|
|
7580
|
+
for name in dict.fromkeys(content.get("files") or []):
|
|
7581
|
+
text = live.get(at + "/" + name if at else name)
|
|
7582
|
+
if text is None:
|
|
7583
|
+
return None, f"{name!r} was not produced for this run"
|
|
7584
|
+
(folder / name).write_text(text, encoding="utf-8")
|
|
7585
|
+
continue
|
|
7586
|
+
files, err = self._pinned_files(content)
|
|
7441
7587
|
if err:
|
|
7442
7588
|
return None, err
|
|
7443
7589
|
for name, path in files:
|
|
7444
|
-
shutil.copy2(path,
|
|
7590
|
+
shutil.copy2(path, folder / name)
|
|
7445
7591
|
(rundir / "run.json").write_text(json.dumps(snap))
|
|
7446
7592
|
return rundir, None
|
|
7447
7593
|
|
|
7448
7594
|
@staticmethod
|
|
7449
|
-
def _pinned_files(
|
|
7450
|
-
"""Each file
|
|
7451
|
-
bytes it pinned at submit -- or (None, why), naming the Source
|
|
7452
|
-
has gone, or the files gone from it or changed since. A
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
if content.get("type") != "source":
|
|
7595
|
+
def _pinned_files(content):
|
|
7596
|
+
"""Each file one Get items content reads, by name, and the path holding
|
|
7597
|
+
the bytes it pinned at submit -- or (None, why), naming the Source
|
|
7598
|
+
that has gone, or the files gone from it or changed since. A content
|
|
7599
|
+
that is not a Source's reads no files."""
|
|
7600
|
+
if not isinstance(content, dict) or content.get("type") != "source":
|
|
7456
7601
|
return [], None
|
|
7457
7602
|
ref = content.get("ref") or {}
|
|
7458
7603
|
label = ref.get("name") or ref.get("id")
|
|
@@ -7582,13 +7727,15 @@ class Queue:
|
|
|
7582
7727
|
continue
|
|
7583
7728
|
if obj.get("event") == "start":
|
|
7584
7729
|
# The item the worker has just picked up, named before
|
|
7585
|
-
# it is asked: what "current" means to a reader.
|
|
7730
|
+
# it is asked: what "current" means to a reader. Its
|
|
7731
|
+
# place too, since an item can repeat: the row in flight.
|
|
7586
7732
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
7587
7733
|
cur = db.execute("SELECT progress FROM queue WHERE id = ?", (rid,)).fetchone()
|
|
7588
7734
|
if cur is None:
|
|
7589
7735
|
return
|
|
7590
7736
|
progress = json.loads(cur[0])
|
|
7591
7737
|
progress["current"] = obj.get("name")
|
|
7738
|
+
progress["at"] = obj["item"] if isinstance(obj["item"], int) else None
|
|
7592
7739
|
db.execute("UPDATE queue SET progress = ? WHERE id = ?",
|
|
7593
7740
|
(json.dumps(progress), rid))
|
|
7594
7741
|
continue
|
|
@@ -7672,6 +7819,13 @@ class Queue:
|
|
|
7672
7819
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
7673
7820
|
db.execute("UPDATE queue SET status = ?, finished_at = ?, error = ?, "
|
|
7674
7821
|
"totals = ? WHERE id = ?", (status, now, error, json.dumps(totals), rid))
|
|
7822
|
+
# A terminal run has no row in flight.
|
|
7823
|
+
cur = db.execute("SELECT progress FROM queue WHERE id = ?", (rid,)).fetchone()
|
|
7824
|
+
if cur is not None:
|
|
7825
|
+
progress = json.loads(cur[0])
|
|
7826
|
+
if progress.get("at") is not None:
|
|
7827
|
+
progress["at"] = None
|
|
7828
|
+
db.execute("UPDATE queue SET progress = ? WHERE id = ?", (json.dumps(progress), rid))
|
|
7675
7829
|
|
|
7676
7830
|
def _read_report(self, rid):
|
|
7677
7831
|
"""The worker's final report, which never shares the progress stream:
|
|
@@ -7894,24 +8048,59 @@ def worker_destinations(run: dict):
|
|
|
7894
8048
|
# is the one its body is read by (#477).
|
|
7895
8049
|
# 18: a dynamic Source's content graded as saved pins one response, `pin:
|
|
7896
8050
|
# {name, n}`, which a fetch, an edit or a Restore never moves (#596).
|
|
7897
|
-
|
|
8051
|
+
# 19: a job's filters are one list around its Response Format, and filters are
|
|
8052
|
+
# seven verbs (#615).
|
|
8053
|
+
PIPELINE_VERSION = 20
|
|
7898
8054
|
# What a stored run may be: the current version, and the ones evals-core.ts's
|
|
7899
8055
|
# upgradePipeline reads. A new submission is upgraded to the current one.
|
|
7900
|
-
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18)
|
|
8056
|
+
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)
|
|
7901
8057
|
TARGET_CAP = 4
|
|
7902
8058
|
# Target steps whose words the Prompt library does not record as a use: they
|
|
7903
8059
|
# ask no model (evals-core.ts's STEP_TYPES.echo).
|
|
7904
8060
|
UNRECORDED_STEPS = {"echo"}
|
|
7905
|
-
|
|
8061
|
+
# The version-20 document is a flat step list and Variants (pipeline-model
|
|
8062
|
+
# §20): `steps` and `variants` in the place version 19 held `jobs` and
|
|
8063
|
+
# `targets`.
|
|
8064
|
+
RUN_FIELDS = ("version", "id", "name", "steps", "variants", "evals", "pass", "profiles", "comment", "plugins")
|
|
8065
|
+
|
|
8066
|
+
|
|
8067
|
+
def request_steps(doc):
|
|
8068
|
+
"""A version-20 document's Request steps, in order (evals-core.ts): one per
|
|
8069
|
+
job, holding the settings every Variant shares."""
|
|
8070
|
+
steps = doc.get("steps") if isinstance(doc, dict) else None
|
|
8071
|
+
return [s for s in (steps if isinstance(steps, list) else [])
|
|
8072
|
+
if isinstance(s, dict) and s.get("type") == "request"]
|
|
8073
|
+
|
|
8074
|
+
|
|
8075
|
+
def merge_cell(base, ov):
|
|
8076
|
+
"""A Variant's cell over a Request's base (evals-core.ts mergeCell): the
|
|
8077
|
+
base where there is no override, the override whole where it carries its
|
|
8078
|
+
own `type` (a different Answered by, or a different shape), else the base
|
|
8079
|
+
with the override's fields."""
|
|
8080
|
+
base = base if isinstance(base, dict) else {}
|
|
8081
|
+
if not isinstance(ov, dict) or not ov:
|
|
8082
|
+
return dict(base)
|
|
8083
|
+
if "type" in ov:
|
|
8084
|
+
return dict(ov)
|
|
8085
|
+
merged = dict(base)
|
|
8086
|
+
merged.update(ov)
|
|
8087
|
+
return merged
|
|
7906
8088
|
|
|
7907
8089
|
|
|
7908
8090
|
def content_of(doc):
|
|
7909
|
-
"""A pipeline's content, where its version keeps it:
|
|
7910
|
-
|
|
7911
|
-
`content` before. The dict itself, so a
|
|
7912
|
-
None for none."""
|
|
8091
|
+
"""A pipeline's content, where its version keeps it: version 20's first Get
|
|
8092
|
+
items step, version 8's job-1 Attach Content step (evals-core.ts
|
|
8093
|
+
contentOf), the document's own `content` before. The dict itself, so a
|
|
8094
|
+
caller may stamp it in place. None for none."""
|
|
7913
8095
|
if not isinstance(doc, dict):
|
|
7914
8096
|
return None
|
|
8097
|
+
steps = doc.get("steps")
|
|
8098
|
+
if isinstance(steps, list) and any(isinstance(s, dict) and s.get("type") in ("getItems", "request") for s in steps):
|
|
8099
|
+
for step in steps:
|
|
8100
|
+
if isinstance(step, dict) and step.get("type") == "getItems":
|
|
8101
|
+
c = step.get("content")
|
|
8102
|
+
return c if isinstance(c, dict) else None
|
|
8103
|
+
return None
|
|
7915
8104
|
jobs = doc.get("jobs") if isinstance(doc.get("jobs"), list) else doc.get("chains")
|
|
7916
8105
|
first = jobs[0] if isinstance(jobs, list) and jobs and isinstance(jobs[0], dict) else None
|
|
7917
8106
|
if first is not None and isinstance(first.get("steps"), list):
|
|
@@ -7922,6 +8111,25 @@ def content_of(doc):
|
|
|
7922
8111
|
return None
|
|
7923
8112
|
c = doc.get("content")
|
|
7924
8113
|
return c if isinstance(c, dict) else None
|
|
8114
|
+
def item_contents(doc):
|
|
8115
|
+
"""Every Get items step's content, in list order, as (step id, content,
|
|
8116
|
+
folder) -- evals-core.ts itemContents: the first's files at the top of a
|
|
8117
|
+
run's files, each later one's under `@N`, N its place among them, a name
|
|
8118
|
+
no item can have (NAME_RESTRICT). A document before version 20 has its
|
|
8119
|
+
one content."""
|
|
8120
|
+
if not isinstance(doc, dict):
|
|
8121
|
+
return []
|
|
8122
|
+
steps = doc.get("steps")
|
|
8123
|
+
if isinstance(steps, list) and any(isinstance(s, dict) and s.get("type") in ("getItems", "request") for s in steps):
|
|
8124
|
+
out = []
|
|
8125
|
+
for k, step in enumerate(s for s in steps if isinstance(s, dict) and s.get("type") == "getItems"):
|
|
8126
|
+
if isinstance(step.get("content"), dict):
|
|
8127
|
+
out.append((step.get("id") or "", step["content"], f"@{k + 1}" if k else ""))
|
|
8128
|
+
return out
|
|
8129
|
+
c = content_of(doc)
|
|
8130
|
+
return [("", c, "")] if c is not None else []
|
|
8131
|
+
|
|
8132
|
+
|
|
7925
8133
|
# The connection-type registry, mirrored from evals-core.ts (#46) because the
|
|
7926
8134
|
# server is stdlib-only and cannot load it: which types ship, and the settings
|
|
7927
8135
|
# each one's options may carry. The server only needs the field allow-list and
|
|
@@ -8200,12 +8408,23 @@ def upgrade_run(run):
|
|
|
8200
8408
|
|
|
8201
8409
|
|
|
8202
8410
|
def cells_of(run):
|
|
8203
|
-
"""Every
|
|
8204
|
-
version
|
|
8205
|
-
and their cells -- what the
|
|
8206
|
-
version a stored run is.
|
|
8411
|
+
"""Every Variant's cell in every Request of a run, as (i, variant, k, cell):
|
|
8412
|
+
version 20's Variants over the Request steps (evals-core.ts targetsOf), an
|
|
8413
|
+
earlier version's targets/scenarios and their steps/cells -- what the
|
|
8414
|
+
Prompt library records a use from, whichever version a stored run is. The
|
|
8415
|
+
cell is the Request's base with the Variant's override over it."""
|
|
8207
8416
|
if not isinstance(run, dict):
|
|
8208
8417
|
return
|
|
8418
|
+
reqs = request_steps(run)
|
|
8419
|
+
if reqs:
|
|
8420
|
+
variants = run.get("variants")
|
|
8421
|
+
for i, v in enumerate(variants if isinstance(variants, list) else []):
|
|
8422
|
+
if not isinstance(v, dict):
|
|
8423
|
+
continue
|
|
8424
|
+
overrides = v.get("overrides") if isinstance(v.get("overrides"), dict) else {}
|
|
8425
|
+
for k, req in enumerate(reqs):
|
|
8426
|
+
yield i, v, k, merge_cell(req.get("cell"), overrides.get(req.get("id")))
|
|
8427
|
+
return
|
|
8209
8428
|
targets = run.get("targets") if isinstance(run.get("targets"), list) else run.get("scenarios")
|
|
8210
8429
|
for i, t in enumerate(targets if isinstance(targets, list) else []):
|
|
8211
8430
|
if not isinstance(t, dict):
|
|
@@ -8229,22 +8448,46 @@ def run_problems(run):
|
|
|
8229
8448
|
_fields(run, "the run", RUN_FIELDS, bad)
|
|
8230
8449
|
if not isinstance(run.get("id"), str) or not run["id"].strip():
|
|
8231
8450
|
bad.append("the run has no id")
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8451
|
+
# The version-20 step list: Get items before Request 1, each Read Response
|
|
8452
|
+
# and Filter after the Request it reads (evals-core.ts stepsProblems). The
|
|
8453
|
+
# server checks the shape and order; tokens and kinds are the runner's.
|
|
8454
|
+
steps = run.get("steps")
|
|
8455
|
+
if not isinstance(steps, list):
|
|
8456
|
+
return bad + ["steps has to be a list"]
|
|
8457
|
+
req_ids = [s.get("id") for s in steps if isinstance(s, dict) and s.get("type") == "request"]
|
|
8458
|
+
seen_ids = set()
|
|
8459
|
+
saw_request = False
|
|
8460
|
+
for pos, s in enumerate(steps):
|
|
8461
|
+
if not isinstance(s, dict) or s.get("type") not in ("getItems", "request", "readResponse", "filter"):
|
|
8462
|
+
bad.append(f"\"{s.get('type') if isinstance(s, dict) else s}\" is not a step this lab has")
|
|
8240
8463
|
continue
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
bad.append(f"
|
|
8246
|
-
|
|
8247
|
-
|
|
8464
|
+
sid = s.get("id")
|
|
8465
|
+
if not isinstance(sid, str) or not sid.strip():
|
|
8466
|
+
bad.append(f"a {s['type']} step has no id")
|
|
8467
|
+
elif sid in seen_ids:
|
|
8468
|
+
bad.append(f"two steps have the id {sid}")
|
|
8469
|
+
else:
|
|
8470
|
+
seen_ids.add(sid)
|
|
8471
|
+
if s["type"] == "getItems":
|
|
8472
|
+
if saw_request:
|
|
8473
|
+
bad.append("Get items comes before Request 1")
|
|
8474
|
+
elif s["type"] == "request":
|
|
8475
|
+
saw_request = True
|
|
8476
|
+
else:
|
|
8477
|
+
# A Read Response or Filter reads the response of a Request, and
|
|
8478
|
+
# comes after it: the step it must come before names the Request.
|
|
8479
|
+
label = "Read Response" if s["type"] == "readResponse" else "Filter"
|
|
8480
|
+
rid = s.get("request")
|
|
8481
|
+
if not isinstance(rid, str) or rid not in req_ids:
|
|
8482
|
+
bad.append(f"a {label} reads a Request the experiment does not have")
|
|
8483
|
+
else:
|
|
8484
|
+
n = req_ids.index(rid) + 1
|
|
8485
|
+
req_pos = next((j for j, o in enumerate(steps)
|
|
8486
|
+
if isinstance(o, dict) and o.get("type") == "request" and o.get("id") == rid), -1)
|
|
8487
|
+
if pos < req_pos:
|
|
8488
|
+
bad.append(f"{label} {n} comes after Request {n}")
|
|
8489
|
+
if not req_ids:
|
|
8490
|
+
return bad + ["add a Request first"]
|
|
8248
8491
|
table = run.get("profiles")
|
|
8249
8492
|
if not isinstance(table, dict):
|
|
8250
8493
|
return bad + ["profiles has to be an object of id → connection"]
|
|
@@ -8269,76 +8512,81 @@ def run_problems(run):
|
|
|
8269
8512
|
bad.append(f"profiles {spelt[var]} and {cid} both take their key from ${var}")
|
|
8270
8513
|
spelt[var] = cid
|
|
8271
8514
|
connection_problems(pid, conn, at, bad)
|
|
8272
|
-
|
|
8273
|
-
if not isinstance(
|
|
8274
|
-
return bad + [f"a run needs between one and {TARGET_CAP}
|
|
8275
|
-
|
|
8276
|
-
for i,
|
|
8277
|
-
at = f"
|
|
8278
|
-
if not isinstance(
|
|
8515
|
+
variants = run.get("variants")
|
|
8516
|
+
if not isinstance(variants, list) or not 1 <= len(variants) <= TARGET_CAP:
|
|
8517
|
+
return bad + [f"a run needs between one and {TARGET_CAP} variants"]
|
|
8518
|
+
vids = [v.get("id") for v in variants if isinstance(v, dict)]
|
|
8519
|
+
for i, v in enumerate(variants):
|
|
8520
|
+
at = f"variant {i + 1}"
|
|
8521
|
+
if not isinstance(v, dict):
|
|
8279
8522
|
bad.append(f"{at} has to be an object")
|
|
8280
8523
|
continue
|
|
8281
8524
|
# The id the Prompt library records a use under (version 4).
|
|
8282
|
-
if not isinstance(
|
|
8525
|
+
if not isinstance(v.get("id"), str) or not v["id"].strip():
|
|
8283
8526
|
bad.append(f"{at} has no id")
|
|
8284
|
-
elif
|
|
8285
|
-
bad.append(f"{at} has the id of another
|
|
8286
|
-
|
|
8287
|
-
if not isinstance(
|
|
8288
|
-
bad.append(f"{at}
|
|
8527
|
+
elif vids.count(v["id"]) > 1:
|
|
8528
|
+
bad.append(f"{at} has the id of another variant")
|
|
8529
|
+
overrides = v.get("overrides")
|
|
8530
|
+
if overrides is not None and (not isinstance(overrides, dict) or any(k not in req_ids for k in overrides)):
|
|
8531
|
+
bad.append(f"{at}: overrides are keyed by Request ids")
|
|
8532
|
+
# Each Variant's cell in each Request -- the Request's base with the
|
|
8533
|
+
# Variant's override over it (cells_of) -- what it sends and the
|
|
8534
|
+
# connection the run carries for it. A Variant with no connection is one
|
|
8535
|
+
# whose cells ask none (Echo) or each name their own; which needs one is
|
|
8536
|
+
# the core's to judge. Whether the words may be blank (Echo answering from
|
|
8537
|
+
# the item) is the core's too, and the worker refuses the run in its words.
|
|
8538
|
+
seen_ref = set()
|
|
8539
|
+
for i, v, k, cell in cells_of(run):
|
|
8540
|
+
at = f"variant {i + 1}, job {k + 1}"
|
|
8541
|
+
if not isinstance(cell, dict) or not isinstance(cell.get("type"), str):
|
|
8542
|
+
bad.append(f"{at} has to name what it sends")
|
|
8289
8543
|
continue
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
if not isinstance(content.get("text"), str) or not content["text"].strip():
|
|
8337
|
-
bad.append("a run over text needs some text")
|
|
8338
|
-
elif kind == "prompt":
|
|
8339
|
-
pass
|
|
8340
|
-
else:
|
|
8341
|
-
bad.append("a run needs a Source's files, some inline text, or Prompt only")
|
|
8544
|
+
if not isinstance(cell.get("prompt"), str):
|
|
8545
|
+
bad.append(f"{at} has no prompt")
|
|
8546
|
+
elif cell.get("from") is not None and not (
|
|
8547
|
+
isinstance(cell["from"], dict) and isinstance(cell["from"].get("id"), str)):
|
|
8548
|
+
bad.append(f"{at} names the prompt it was picked from without an id")
|
|
8549
|
+
c = cell.get("connection") if cell.get("connection") is not None else v.get("connection")
|
|
8550
|
+
p = cell.get("profile") if cell.get("profile") is not None else v.get("profile")
|
|
8551
|
+
if c is None and p is None:
|
|
8552
|
+
continue
|
|
8553
|
+
if (not isinstance(c, dict) or not isinstance(c.get("id"), str)
|
|
8554
|
+
or (p is not None and not (isinstance(p, dict) and isinstance(p.get("id"), str)))
|
|
8555
|
+
or run_answer(table, answer_key(c["id"], p["id"] if p else None)) is None):
|
|
8556
|
+
if i not in seen_ref:
|
|
8557
|
+
seen_ref.add(i)
|
|
8558
|
+
bad.append(f"variant {i + 1} names a connection the run does not carry")
|
|
8559
|
+
# Each Get items step's content; a later one is named by its place, and
|
|
8560
|
+
# may add no files (a run of one file of the first, say).
|
|
8561
|
+
contents = item_contents(run) or [("", None, "")]
|
|
8562
|
+
for k, (_step, content, _at) in enumerate(contents):
|
|
8563
|
+
said = []
|
|
8564
|
+
kind = content.get("type") if isinstance(content, dict) else None
|
|
8565
|
+
if kind == "source":
|
|
8566
|
+
ref = content.get("ref")
|
|
8567
|
+
files = content.get("files")
|
|
8568
|
+
if not isinstance(ref, dict) or not isinstance(ref.get("id"), str) or not ref["id"]:
|
|
8569
|
+
said.append("a Source content has to name its Source")
|
|
8570
|
+
# A live dynamic run (docs/dynamic-sources.md) produces its items at
|
|
8571
|
+
# submit, so its file list is the server's to fill -- empty here is
|
|
8572
|
+
# fine. Every other source run names the files it froze.
|
|
8573
|
+
elif content.get("live") is True:
|
|
8574
|
+
if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
|
|
8575
|
+
said.append("a run over a Source needs its file list")
|
|
8576
|
+
elif not isinstance(files, list) or not (files or k) or not all(isinstance(f, str) for f in files):
|
|
8577
|
+
said.append("a run over a Source needs its file list")
|
|
8578
|
+
pin = content.get("pin")
|
|
8579
|
+
if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
|
|
8580
|
+
and type(pin.get("n")) is int and pin["n"] >= 0):
|
|
8581
|
+
said.append("a Source content's pin names a response as { name, n }")
|
|
8582
|
+
elif kind == "text":
|
|
8583
|
+
if not isinstance(content.get("text"), str) or not content["text"].strip():
|
|
8584
|
+
said.append("a run over text needs some text")
|
|
8585
|
+
elif kind == "prompt":
|
|
8586
|
+
pass
|
|
8587
|
+
else:
|
|
8588
|
+
said.append("a run needs a Source's files, some inline text, or (No content)")
|
|
8589
|
+
bad.extend(f"Get items {k + 1}: {x}" if k else x for x in said)
|
|
8342
8590
|
evals = run.get("evals")
|
|
8343
8591
|
if not isinstance(evals, list) or not all(isinstance(t, dict) and isinstance(t.get("type"), str) for t in evals):
|
|
8344
8592
|
bad.append("evals has to be a list of evals, each naming its type")
|
|
@@ -8514,7 +8762,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8514
8762
|
|
|
8515
8763
|
# The eval groups' routes (docs/pipeline-model.md §17) are the datasets'
|
|
8516
8764
|
# under their new name: /api/datasets goes on answering the same rows, so
|
|
8517
|
-
#
|
|
8765
|
+
# the app's comparison (docs/app-data.md) and a script written against it
|
|
8766
|
+
# keep working: another repository's contract, so keep its shape. A list
|
|
8518
8767
|
# asked for by the new name is `groups`.
|
|
8519
8768
|
GROUPS_ROUTE = "/api/eval-groups"
|
|
8520
8769
|
grouped = False
|
|
@@ -8608,8 +8857,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8608
8857
|
return self._json(400, {"error": "limit has to be a number"})
|
|
8609
8858
|
before = (query.get("before") or [None])[0]
|
|
8610
8859
|
before_id = (query.get("beforeId") or [None])[0]
|
|
8611
|
-
|
|
8612
|
-
|
|
8860
|
+
pipeline = (query.get("pipeline") or [None])[0]
|
|
8861
|
+
runs, more = QUEUE.list(limit, before, before_id, full=(query.get("full") or [""])[0] == "1",
|
|
8862
|
+
pipeline=pipeline)
|
|
8863
|
+
# Which version of a Library prompt each target step ran: the
|
|
8864
|
+
# prompt library records it, not the run's document (§14), and
|
|
8865
|
+
# Overview breaks a target's line where it changes.
|
|
8866
|
+
used = PROMPTS.used_by([r["id"] for r in runs]) if PROMPTS else {}
|
|
8867
|
+
return self._json(200, {"runs": [{**r, "prompts": used.get(r["id"], [])} for r in runs], "more": more})
|
|
8613
8868
|
if path.startswith("/api/queue/") and path.endswith("/groups") and path.count("/") == 4:
|
|
8614
8869
|
# The bodies of the eval groups a run grades with, by `<id>@<n>`
|
|
8615
8870
|
# (§17); null for a run that kept none, as /dataset answers.
|
|
@@ -8650,10 +8905,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8650
8905
|
run["behind"] = QUEUE._behind(run["id"]) or 0
|
|
8651
8906
|
return self._json(200, run)
|
|
8652
8907
|
if path == "/api/presets":
|
|
8653
|
-
#
|
|
8654
|
-
|
|
8655
|
-
own = json.loads(PRESETS.read_text("utf-8")) if PRESETS is not None else []
|
|
8656
|
-
return self._json(200, own + (PACKS.presets() if PACKS is not None else []))
|
|
8908
|
+
# Every installed pack's: the lab ships none of its own (#652).
|
|
8909
|
+
return self._json(200, PACKS.presets() if PACKS is not None else [])
|
|
8657
8910
|
if path == "/api/plugins":
|
|
8658
8911
|
if PLUGINS is None:
|
|
8659
8912
|
return self._send(404, b"not found", "text/plain")
|
|
@@ -9348,28 +9601,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9348
9601
|
|
|
9349
9602
|
# ---- The run queue (#530) ----------------------------------------------
|
|
9350
9603
|
|
|
9351
|
-
def
|
|
9352
|
-
"""
|
|
9353
|
-
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
run = self._payload()
|
|
9358
|
-
if run is None:
|
|
9359
|
-
return self._json(400, {"error": "bad body"})
|
|
9360
|
-
run, why = upgrade_run(run)
|
|
9361
|
-
if why:
|
|
9362
|
-
return self._json(400, {"error": "the run cannot be queued: " + why})
|
|
9363
|
-
bad = run_problems(run)
|
|
9364
|
-
if bad:
|
|
9365
|
-
return self._json(400, {"error": "the run cannot be queued: " + "; ".join(bad)})
|
|
9366
|
-
content = content_of(run)
|
|
9604
|
+
def _stamp_content(self, content):
|
|
9605
|
+
"""One Get items step's content made ready to queue, in place: a
|
|
9606
|
+
Source's files pinned at their revision, or a dynamic Source's items
|
|
9607
|
+
produced or its pinned response read. Returns (produced, None) -- the
|
|
9608
|
+
bytes kept with the run by item name, or None for none -- or (None,
|
|
9609
|
+
(status, error))."""
|
|
9367
9610
|
produced = None
|
|
9368
9611
|
if content["type"] == "source":
|
|
9369
9612
|
sid = content["ref"]["id"]
|
|
9370
9613
|
src = SOURCES.get(sid)
|
|
9371
9614
|
if src is None:
|
|
9372
|
-
return
|
|
9615
|
+
return None, (404, "no such Source")
|
|
9373
9616
|
# A dynamic Source (docs/dynamic-sources.md) grades either its stored
|
|
9374
9617
|
# Calls (snapshot) or content produced fresh at submit (live). The
|
|
9375
9618
|
# choice is the run's (content.live) over the Source's default
|
|
@@ -9379,20 +9622,19 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9379
9622
|
if src.get("type") == "dynamic":
|
|
9380
9623
|
entry = source_entry("dynamic", src.get("config") or {})
|
|
9381
9624
|
if entry is None:
|
|
9382
|
-
return
|
|
9625
|
+
return None, (403, "the lab does not offer that Source's provider")
|
|
9383
9626
|
cfg = src.get("config") or {}
|
|
9384
9627
|
if entry.get("produce") is None:
|
|
9385
9628
|
# A Supplied provider hands items in (the caller's --items,
|
|
9386
9629
|
# or an Upload…); the server produces nothing, so there is no
|
|
9387
9630
|
# live for it -- a hosted run grades the items it holds. A
|
|
9388
|
-
# Source with none is refused
|
|
9631
|
+
# Source with none is refused by run_problems, naming
|
|
9389
9632
|
# the Source with no files; a run that asks it to go live is
|
|
9390
9633
|
# refused here, pointing at the CLI (docs/ci.md).
|
|
9391
9634
|
if content.get("live"):
|
|
9392
9635
|
noun = entry.get("noun", "item")
|
|
9393
|
-
return
|
|
9394
|
-
|
|
9395
|
-
f"-- add one, or run it from the CLI with evals-lab run --items"})
|
|
9636
|
+
return None, (400, f"this Source produces nothing: it grades the {noun}s handed in "
|
|
9637
|
+
f"-- add one, or run it from the CLI with evals-lab run --items")
|
|
9396
9638
|
content["live"] = False
|
|
9397
9639
|
else:
|
|
9398
9640
|
live = content.get("live")
|
|
@@ -9405,14 +9647,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9405
9647
|
# written by hand is graded as saved.
|
|
9406
9648
|
produced, err = self._produce_endpoints(sid, entry, cfg, content["files"])
|
|
9407
9649
|
if err:
|
|
9408
|
-
return
|
|
9650
|
+
return None, err
|
|
9409
9651
|
content["live"] = True
|
|
9410
9652
|
content["revs"] = {name: "live:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
9411
9653
|
for name, text in produced.items()}
|
|
9412
9654
|
elif live:
|
|
9413
9655
|
produced, name, err = self._produce_live(sid, entry, cfg)
|
|
9414
9656
|
if err:
|
|
9415
|
-
return
|
|
9657
|
+
return None, err
|
|
9416
9658
|
content["live"] = True
|
|
9417
9659
|
# The one capture, looped as a snapshot's files are
|
|
9418
9660
|
# (evals-core.ts expand): Run first cannot cut below
|
|
@@ -9430,8 +9672,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9430
9672
|
# whatever a fetch, an edit or a Restore made current since.
|
|
9431
9673
|
body = SOURCES.pinned_body(sid, pin["name"], pin["n"])
|
|
9432
9674
|
if body is None:
|
|
9433
|
-
return
|
|
9434
|
-
|
|
9675
|
+
return None, (400, f"the response this run grades, {pin['name']}, "
|
|
9676
|
+
"was removed: pick another")
|
|
9435
9677
|
text = body.decode("utf-8", errors="replace")
|
|
9436
9678
|
produced = {pin["name"]: text}
|
|
9437
9679
|
loops = content.get("loops")
|
|
@@ -9445,8 +9687,34 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9445
9687
|
for name in dict.fromkeys(content["files"]):
|
|
9446
9688
|
revs[name] = SOURCES.rev(sid, name)
|
|
9447
9689
|
if revs[name] is None:
|
|
9448
|
-
return
|
|
9690
|
+
return None, (400, f"{name!r} is not in that Source")
|
|
9449
9691
|
content["revs"] = revs
|
|
9692
|
+
return produced, None
|
|
9693
|
+
|
|
9694
|
+
def _queue_submit(self):
|
|
9695
|
+
"""A run document, checked and queued. The page resolves it at submit
|
|
9696
|
+
(evals-core.ts resolvePipeline); anything else posting here is held to
|
|
9697
|
+
the same fields."""
|
|
9698
|
+
if QUEUE is None:
|
|
9699
|
+
return self._send(404, b"not found", "text/plain")
|
|
9700
|
+
run = self._payload()
|
|
9701
|
+
if run is None:
|
|
9702
|
+
return self._json(400, {"error": "bad body"})
|
|
9703
|
+
run, why = upgrade_run(run)
|
|
9704
|
+
if why:
|
|
9705
|
+
return self._json(400, {"error": "the run cannot be queued: " + why})
|
|
9706
|
+
bad = run_problems(run)
|
|
9707
|
+
if bad:
|
|
9708
|
+
return self._json(400, {"error": "the run cannot be queued: " + "; ".join(bad)})
|
|
9709
|
+
produced = None
|
|
9710
|
+
for _step, content, at in item_contents(run):
|
|
9711
|
+
got, err = self._stamp_content(content)
|
|
9712
|
+
if err:
|
|
9713
|
+
return self._json(err[0], {"error": err[1]})
|
|
9714
|
+
if got is not None:
|
|
9715
|
+
# A later Get items' items are kept under its own folder
|
|
9716
|
+
# (evals-core.ts itemContents), so two Sources' names never meet.
|
|
9717
|
+
produced = {**(produced or {}), **{(at + "/" + n if at else n): t for n, t in got.items()}}
|
|
9450
9718
|
# A connection not shared with this workspace blocks the run with the
|
|
9451
9719
|
# sentence the Run bar shows and the connections it names, so its Share
|
|
9452
9720
|
# link can grant them at once (docs/workspaces.md).
|
|
@@ -9503,8 +9771,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9503
9771
|
# The worker reads a body per group now (#233), so a run may link
|
|
9504
9772
|
# several; each body is kept with the row under `<id>@<n>`. A live
|
|
9505
9773
|
# dynamic run carries its produced item bytes too.
|
|
9506
|
-
|
|
9507
|
-
|
|
9774
|
+
# Each target step's prompt, as recorded: a step typed with no `from`
|
|
9775
|
+
# takes it, so its next edit versions that prompt (docs/pipeline-model.md §14).
|
|
9776
|
+
recorded = []
|
|
9777
|
+
row = QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None, produced=produced,
|
|
9778
|
+
recorded=recorded)
|
|
9779
|
+
return self._json(201, {"run": row, "prompts": recorded})
|
|
9508
9780
|
|
|
9509
9781
|
@staticmethod
|
|
9510
9782
|
def _produce_endpoints(sid, entry, cfg, files):
|