evals-lab 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +2 -2
- package/bin/run.js +4 -2
- package/lab/VERSION +1 -1
- package/lab/demo/CREDITS.md +14 -0
- package/lab/demo/datasets/demo-1.json +2 -2
- package/lab/demo/datasets/demo-2.json +2 -2
- package/lab/demo/datasets/documents-type.json +79 -0
- package/lab/demo/datasets/documents-valid.json +117 -0
- package/lab/demo/datasets/sentiment.json +2 -2
- package/lab/demo/datasets/weather-alerts.json +2 -2
- package/lab/demo/datasets/wordings.json +1 -1
- package/lab/demo/manifest.json +3 -3
- package/lab/demo/pipelines/demo-1.json +4 -4
- package/lab/demo/pipelines/demo-2.json +4 -4
- package/lab/demo/pipelines/documents.json +102 -0
- package/lab/demo/pipelines/sentiment.json +4 -4
- package/lab/demo/pipelines/weather-alerts.json +9 -5
- package/lab/demo/pipelines/wordings.json +3 -3
- package/lab/demo/sources/Demo 1/source.json +1 -1
- package/lab/demo/sources/Demo 2/source.json +1 -1
- package/lab/demo/sources/documents/invoice.pdf +65 -0
- package/lab/demo/sources/documents/letter.pdf +63 -0
- package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
- package/lab/demo/sources/documents/receipt.pdf +62 -0
- package/lab/demo/sources/documents/report.pdf +59 -0
- package/lab/demo/sources/documents/source.json +3 -0
- package/lab/demo/sources/documents/truncated.pdf +19 -0
- package/lab/demo/sources/sentiment/source.json +1 -1
- package/lab/demo/sources/weather-alerts/source.json +1 -1
- package/lab/evals-core.mjs +382 -84
- package/lab/flows/record.mjs +5 -4
- package/lab/flows/wdl.mjs +19 -0
- package/lab/kinds/list.mjs +77 -23
- package/lab/metrics/builtin.mjs +34 -7
- package/lab/run-evals.js +33 -12
- package/lab/server.py +668 -108
- package/lab/web/dist/assets/gallery-qrf0ZGXK.js +5 -0
- package/lab/web/dist/assets/main-D6xARg2A.js +16 -0
- package/lab/web/dist/assets/main-xVU6J7Gi.css +1 -0
- package/lab/web/dist/assets/tokens-C8dqXDHN.js +62 -0
- package/lab/web/dist/assets/tokens-tQ7zTPkd.css +1 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-CFntjQFG.js +0 -5
- package/lab/web/dist/assets/main-BlTG2i20.css +0 -1
- package/lab/web/dist/assets/main-D6MNWqak.js +0 -17
- package/lab/web/dist/assets/tokens-DwzzdSIF.css +0 -1
- package/lab/web/dist/assets/tokens-bvuNFuYg.js +0 -60
package/lab/server.py
CHANGED
|
@@ -50,6 +50,7 @@ from pathlib import Path
|
|
|
50
50
|
# Ollama's own address on this machine, unless OLLAMA_URL names another: the
|
|
51
51
|
# deployed lab's is set in the Dockerfile, and no default names a host of its.
|
|
52
52
|
OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
|
|
53
|
+
|
|
53
54
|
# The password in front of everything but /healthz (#463). Unset means open,
|
|
54
55
|
# as a lab run on a workstation always has been; the deployed one sets it,
|
|
55
56
|
# because it keeps setups and API keys and can spend GPU time.
|
|
@@ -145,6 +146,16 @@ def host_of(url: str) -> str:
|
|
|
145
146
|
return (urllib.parse.urlsplit(url).hostname or "").lower()
|
|
146
147
|
|
|
147
148
|
|
|
149
|
+
def ollama_shown():
|
|
150
|
+
"""OLLAMA as the page may show it, where a connection with no address
|
|
151
|
+
goes: no user or password, should OLLAMA_URL carry one."""
|
|
152
|
+
try:
|
|
153
|
+
u = urllib.parse.urlsplit(OLLAMA)
|
|
154
|
+
return urllib.parse.urlunsplit(u._replace(netloc=u.netloc.rpartition("@")[2]))
|
|
155
|
+
except ValueError:
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
|
|
148
159
|
# The chat endpoint each connection type's request reaches, off the /v1
|
|
149
160
|
# base -- evals-core.ts's CONNECTION_TYPES, mirrored because the server is
|
|
150
161
|
# stdlib-only and cannot load it. Ollama's native /api/chat sits at the root;
|
|
@@ -311,6 +322,8 @@ FILE_TYPES = {
|
|
|
311
322
|
".dng": "image/x-adobe-dng",
|
|
312
323
|
".tif": "image/tiff",
|
|
313
324
|
".tiff": "image/tiff",
|
|
325
|
+
# Handed to the model whole, as a document (evals-core.ts DOCUMENT_TYPES).
|
|
326
|
+
".pdf": "application/pdf",
|
|
314
327
|
".txt": "text/plain; charset=utf-8",
|
|
315
328
|
".md": "text/markdown; charset=utf-8",
|
|
316
329
|
".csv": "text/csv; charset=utf-8",
|
|
@@ -362,13 +375,13 @@ _CALL_UPLOADS = {".txt": "text/plain; charset=utf-8",
|
|
|
362
375
|
".csv": "text/csv; charset=utf-8",
|
|
363
376
|
".md": "text/markdown; charset=utf-8"}
|
|
364
377
|
SOURCE_PROVIDERS = {
|
|
365
|
-
"http-get": {"label": "HTTP GET", "noun": "
|
|
378
|
+
"http-get": {"label": "HTTP GET", "noun": "response", "tier": "pull", "flag": None,
|
|
366
379
|
"uploads": dict(_CALL_UPLOADS)},
|
|
367
380
|
# The API call provider (docs/dynamic-sources.md § 2): HTTP GET plus a verb,
|
|
368
381
|
# headers and a body, keyed from a connection -- the same relay fetch, one
|
|
369
382
|
# layer up. `produce` and `take` are attached below, beside http-get's.
|
|
370
383
|
# `keyed`: it sends a connection's key, so a pack may not carry one (read_pack).
|
|
371
|
-
"api": {"label": "API call", "noun": "
|
|
384
|
+
"api": {"label": "API call", "noun": "response", "tier": "pull", "flag": None, "keyed": True,
|
|
372
385
|
"uploads": dict(_CALL_UPLOADS), "longConfig": {"body": 8000}},
|
|
373
386
|
# Supplied has no `produce`: the caller hands items in (the CLI's --items, or
|
|
374
387
|
# an Upload… by hand), so the server fetches and executes nothing -- it only
|
|
@@ -655,7 +668,44 @@ def take_record(name, data):
|
|
|
655
668
|
return json.dumps(kept, indent=2).encode("utf-8"), None
|
|
656
669
|
|
|
657
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
|
+
|
|
658
706
|
WORKFLOW_PLATFORMS["power-automate"]["take"] = take_record
|
|
707
|
+
WORKFLOW_PLATFORMS["power-automate"]["describe"] = describe_record
|
|
708
|
+
WORKFLOW_PLATFORMS["power-automate"]["key"] = record_key
|
|
659
709
|
|
|
660
710
|
|
|
661
711
|
# ---- A dynamic Source's Calls ------------------------------------------------
|
|
@@ -1212,9 +1262,20 @@ def workspace_of(store) -> str:
|
|
|
1212
1262
|
|
|
1213
1263
|
|
|
1214
1264
|
# The `connection_shares` sentinels (phase 4 fills and enforces the table;
|
|
1215
|
-
# phase 1 only creates it empty): every workspace,
|
|
1265
|
+
# phase 1 only creates it empty): every workspace, future ones, and none.
|
|
1266
|
+
# No rows is All, so a set that loses its last workspace (a purge, the Share
|
|
1267
|
+
# link's Undo, a stale id) holds SHARE_NONE rather than nothing (#506).
|
|
1216
1268
|
SHARE_ALL = "*all"
|
|
1217
1269
|
SHARE_NEW = "*new"
|
|
1270
|
+
SHARE_NONE = "*none"
|
|
1271
|
+
|
|
1272
|
+
def hold_nowhere(db, conns) -> None:
|
|
1273
|
+
"""SHARE_NONE for each (kind, id) in [conns] left with no share row, which
|
|
1274
|
+
would otherwise read as shared with every workspace."""
|
|
1275
|
+
db.executemany("INSERT INTO connection_shares (kind, id, workspace) SELECT ?, ?, ? "
|
|
1276
|
+
"WHERE NOT EXISTS (SELECT 1 FROM connection_shares WHERE kind = ? AND id = ?)",
|
|
1277
|
+
[(k, i, SHARE_NONE, k, i) for k, i in conns])
|
|
1278
|
+
|
|
1218
1279
|
|
|
1219
1280
|
# How the `docs` table scopes by key (docs/workspaces.md): workflows are
|
|
1220
1281
|
# per-workspace, profiles/tokens global, and promptlab.versions is split --
|
|
@@ -1519,7 +1580,8 @@ class Store:
|
|
|
1519
1580
|
# An AI Connection or an Account (kind "profile"/"account", id the
|
|
1520
1581
|
# connection's -- the Target profile's it kept -- or the account's) is
|
|
1521
1582
|
# usable in a workspace by its share set in `connection_shares`: a row for
|
|
1522
|
-
# that workspace, or the SHARE_ALL sentinel.
|
|
1583
|
+
# that workspace, or the SHARE_ALL sentinel. SHARE_NONE is a row that
|
|
1584
|
+
# matches no workspace, so a set holding only it is usable nowhere.
|
|
1523
1585
|
# No row at all is the migration and new-connection default -- shared with
|
|
1524
1586
|
# every workspace, so nothing stops running the moment workspaces exist
|
|
1525
1587
|
# (open question 2, resolved All); narrowing a connection is adding the
|
|
@@ -1540,7 +1602,7 @@ class Store:
|
|
|
1540
1602
|
"""Every connection's share set, for the Connections menus: keyed by
|
|
1541
1603
|
"<kind>:<id>", the workspace values as stored (workspace ids and the
|
|
1542
1604
|
sentinels). A connection with no row is absent, which the page reads as
|
|
1543
|
-
shared with All. No key is anywhere in this (#258)."""
|
|
1605
|
+
shared with All; one with SHARE_NONE is shared with no workspace. No key is anywhere in this (#258)."""
|
|
1544
1606
|
out: dict = {}
|
|
1545
1607
|
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
1546
1608
|
for kind, cid, ws in db.execute("SELECT kind, id, workspace FROM connection_shares"):
|
|
@@ -1548,15 +1610,20 @@ class Store:
|
|
|
1548
1610
|
return out
|
|
1549
1611
|
|
|
1550
1612
|
def set_shares(self, kind: str, cid: str, workspaces) -> None:
|
|
1551
|
-
"""Replace a connection's share set. SHARE_ALL means every workspace
|
|
1552
|
-
so
|
|
1553
|
-
in, so a stale id cannot linger in the table.
|
|
1613
|
+
"""Replace a connection's share set. SHARE_ALL means every workspace
|
|
1614
|
+
and SHARE_NONE none, so each is kept alone; only the sentinels and live
|
|
1615
|
+
workspace ids are let in, so a stale id cannot linger in the table. A
|
|
1616
|
+
set named but left empty by that is SHARE_NONE, not the All an empty
|
|
1617
|
+
set reads as; an empty set asked for is a plain delete, so a failed
|
|
1618
|
+
lab import's rollback leaves no row for a connection it never wrote."""
|
|
1554
1619
|
with self.lock, closing(sqlite3.connect(self.path)) as db, db:
|
|
1555
1620
|
valid = {r[0] for r in db.execute("SELECT id FROM workspaces WHERE trash IS NULL")}
|
|
1556
1621
|
chosen = [w for w in dict.fromkeys(workspaces or [])
|
|
1557
|
-
if w in (SHARE_ALL, SHARE_NEW) or w in valid]
|
|
1622
|
+
if w in (SHARE_ALL, SHARE_NEW, SHARE_NONE) or w in valid]
|
|
1558
1623
|
if SHARE_ALL in chosen:
|
|
1559
1624
|
chosen = [SHARE_ALL]
|
|
1625
|
+
elif SHARE_NONE in chosen or (workspaces and not chosen):
|
|
1626
|
+
chosen = [SHARE_NONE]
|
|
1560
1627
|
db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ?", (kind, cid))
|
|
1561
1628
|
db.executemany("INSERT INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
|
|
1562
1629
|
[(kind, cid, w) for w in chosen])
|
|
@@ -1565,14 +1632,18 @@ class Store:
|
|
|
1565
1632
|
"""Add or remove one workspace from a connection's share set -- the Run
|
|
1566
1633
|
bar's Share link and its Undo (docs/workspaces.md). The link is offered
|
|
1567
1634
|
only where the connection is actually narrowed, so adding a row is what
|
|
1568
|
-
grants the blocked workspace its use and Undo takes it back
|
|
1635
|
+
grants the blocked workspace its use and Undo takes it back -- to
|
|
1636
|
+
SHARE_NONE when it was the last, as a set shared nowhere was."""
|
|
1569
1637
|
with self.lock, closing(sqlite3.connect(self.path)) as db, db:
|
|
1570
1638
|
if on:
|
|
1571
1639
|
db.execute("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) "
|
|
1572
1640
|
"VALUES (?, ?, ?)", (kind, cid, ws))
|
|
1641
|
+
db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
|
|
1642
|
+
(kind, cid, SHARE_NONE))
|
|
1573
1643
|
else:
|
|
1574
1644
|
db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
|
|
1575
1645
|
(kind, cid, ws))
|
|
1646
|
+
hold_nowhere(db, [(kind, cid)])
|
|
1576
1647
|
|
|
1577
1648
|
def workspace_name(self, ws) -> str:
|
|
1578
1649
|
"""A workspace's name, for the refusal sentence the Run bar shows."""
|
|
@@ -1702,8 +1773,11 @@ class Workspaces:
|
|
|
1702
1773
|
if isinstance(shares, list)
|
|
1703
1774
|
else list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?",
|
|
1704
1775
|
(SHARE_NEW,))))
|
|
1776
|
+
grant = [(k, i) for k, i in grant if k and i]
|
|
1705
1777
|
db.executemany("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
|
|
1706
|
-
[(k, i, wid) for k, i in grant
|
|
1778
|
+
[(k, i, wid) for k, i in grant])
|
|
1779
|
+
db.executemany("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
|
|
1780
|
+
[(k, i, SHARE_NONE) for k, i in grant])
|
|
1707
1781
|
return wid, None
|
|
1708
1782
|
|
|
1709
1783
|
def rename(self, wid, name) -> tuple:
|
|
@@ -1822,6 +1896,9 @@ class Workspaces:
|
|
|
1822
1896
|
"(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
|
|
1823
1897
|
db.execute("DELETE FROM source_definitions WHERE source IN "
|
|
1824
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,))
|
|
1825
1902
|
db.execute("DELETE FROM sources WHERE workspace = ? AND system = 0", (ws,))
|
|
1826
1903
|
db.execute("DELETE FROM eval_group_versions WHERE group_id IN "
|
|
1827
1904
|
"(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
|
|
@@ -1843,7 +1920,11 @@ class Workspaces:
|
|
|
1843
1920
|
db.execute("DELETE FROM queue WHERE workspace = ?", (ws,))
|
|
1844
1921
|
db.execute("DELETE FROM runs WHERE workspace = ?", (ws,))
|
|
1845
1922
|
db.execute("DELETE FROM docs WHERE workspace = ?", (ws,))
|
|
1923
|
+
# A connection shared only here is shared nowhere after, not with
|
|
1924
|
+
# All, which is what no rows would read as (#506).
|
|
1925
|
+
held = list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?", (ws,)))
|
|
1846
1926
|
db.execute("DELETE FROM connection_shares WHERE workspace = ?", (ws,))
|
|
1927
|
+
hold_nowhere(db, held)
|
|
1847
1928
|
db.execute("DELETE FROM workspaces WHERE id = ?", (ws,))
|
|
1848
1929
|
return sids, rids
|
|
1849
1930
|
|
|
@@ -2005,6 +2086,17 @@ class Sources:
|
|
|
2005
2086
|
"origin TEXT NOT NULL, at TEXT NOT NULL, "
|
|
2006
2087
|
"provider TEXT, request TEXT, status INTEGER, error TEXT, file TEXT, "
|
|
2007
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))")
|
|
2008
2100
|
db.execute("INSERT OR IGNORE INTO sources (id, name, system, bytes, created_at) "
|
|
2009
2101
|
"VALUES (?, 'Sample library', 1, 0, ?)",
|
|
2010
2102
|
(self.SYSTEM_ID, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
@@ -2215,10 +2307,11 @@ class Sources:
|
|
|
2215
2307
|
if m.get("kind") == "source" and isinstance(m.get("source"), str):
|
|
2216
2308
|
held.add(m["source"])
|
|
2217
2309
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2218
|
-
for
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
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,))
|
|
2222
2315
|
|
|
2223
2316
|
def type_of(self, sid):
|
|
2224
2317
|
"""A Source's enforcement entry -- its kind's, or its workflow
|
|
@@ -2405,6 +2498,16 @@ class Sources:
|
|
|
2405
2498
|
if missing:
|
|
2406
2499
|
return None, (404, f"{missing[0]!r} is not in that Source")
|
|
2407
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))]
|
|
2408
2511
|
with db:
|
|
2409
2512
|
for name in names:
|
|
2410
2513
|
db.execute("DELETE FROM source_files WHERE source = ? AND name = ?",
|
|
@@ -2413,6 +2516,8 @@ class Sources:
|
|
|
2413
2516
|
# with it (a File Library has no such rows).
|
|
2414
2517
|
db.execute("DELETE FROM source_calls WHERE source = ? AND file = ?",
|
|
2415
2518
|
(sid, name))
|
|
2519
|
+
db.execute("DELETE FROM source_responses WHERE source = ? AND name = ?",
|
|
2520
|
+
(sid, name))
|
|
2416
2521
|
db.execute("UPDATE sources SET bytes = bytes - ? WHERE id = ?",
|
|
2417
2522
|
(total, sid))
|
|
2418
2523
|
token = secrets.token_hex(6)
|
|
@@ -2421,7 +2526,8 @@ class Sources:
|
|
|
2421
2526
|
(entry / "thumbs").mkdir(parents=True)
|
|
2422
2527
|
(entry / "manifest.json").write_text(json.dumps({
|
|
2423
2528
|
"kind": "files", "source": sid, "at": time.time(),
|
|
2424
|
-
"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}))
|
|
2425
2531
|
src = self.dir / sid
|
|
2426
2532
|
for name in names:
|
|
2427
2533
|
shutil.move(str(src / name), str(entry / "files" / name))
|
|
@@ -2486,6 +2592,12 @@ class Sources:
|
|
|
2486
2592
|
if r["file"] and r["file"] in sizes:
|
|
2487
2593
|
call["bytes"] = sizes[r["file"]]
|
|
2488
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"]}
|
|
2489
2601
|
return out
|
|
2490
2602
|
|
|
2491
2603
|
def _record_call(self, sid, name, origin, provider, request, status, error, file):
|
|
@@ -2499,11 +2611,18 @@ class Sources:
|
|
|
2499
2611
|
provider, json.dumps(request) if request else None, status, error, file))
|
|
2500
2612
|
|
|
2501
2613
|
def snapshot(self, sid):
|
|
2502
|
-
"""
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2614
|
+
"""Fetch now (#587): produce the Source's Call through the provider,
|
|
2615
|
+
the `take` hook refusing a token and redacting, and keep it as a
|
|
2616
|
+
version of the response its name says (#596). A response already held
|
|
2617
|
+
gains the version and keeps its row, whatever was done to it -- an edit
|
|
2618
|
+
is a version too, so nothing forks -- unless the bytes are the current
|
|
2619
|
+
version's, which adds nothing. A name not held is a new response. A
|
|
2620
|
+
failed fetch leaves a response's current version as it is; one with
|
|
2621
|
+
no response yet is kept as a Call with its error, not dropped.
|
|
2622
|
+
Returns ({name, outcome, ...}, None) -- outcome `fetched`,
|
|
2623
|
+
`unchanged`, `added` or `failed`, and for `fetched` the response it
|
|
2624
|
+
replaced (`was`) and the one it added (`n`), what Undo hands back --
|
|
2625
|
+
or (None, error)."""
|
|
2507
2626
|
got, err = self._dynamic_entry(sid)
|
|
2508
2627
|
if err:
|
|
2509
2628
|
return None, err
|
|
@@ -2518,46 +2637,231 @@ class Sources:
|
|
|
2518
2637
|
request = result.get("request")
|
|
2519
2638
|
status = result.get("status")
|
|
2520
2639
|
content = result.get("content")
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2640
|
+
failure = result.get("error")
|
|
2641
|
+
name = kept = None
|
|
2642
|
+
if content is not None:
|
|
2643
|
+
name = dynamic_item_name(cfg, content)
|
|
2644
|
+
kept, take_err = entry["take"](name, content)
|
|
2645
|
+
if take_err:
|
|
2646
|
+
failure = take_err[1]
|
|
2647
|
+
else:
|
|
2648
|
+
name = dynamic_item_name(cfg, (request or {}).get("url", "") or secrets.token_hex(6))
|
|
2649
|
+
path = self.file_path(sid, name)
|
|
2650
|
+
held = path is not None and path.is_file()
|
|
2651
|
+
if failure is not None:
|
|
2652
|
+
if not held:
|
|
2653
|
+
self._record_call(sid, name, "snapshot", provider, request, status, failure, None)
|
|
2654
|
+
return {"name": name, "outcome": "failed", "provider": provider,
|
|
2655
|
+
"request": request, "status": status, "error": failure}, None
|
|
2656
|
+
if not held:
|
|
2657
|
+
stored, add_err = self.add_file(sid, name, kept)
|
|
2658
|
+
if add_err:
|
|
2659
|
+
return None, add_err
|
|
2660
|
+
self._record_call(sid, stored, "snapshot", provider, request, status, None, stored)
|
|
2661
|
+
return {"name": stored, "outcome": "added", "provider": provider, "request": request,
|
|
2662
|
+
"status": status, "bytes": len(kept)}, None
|
|
2663
|
+
if path.read_bytes() == kept:
|
|
2664
|
+
return {"name": name, "outcome": "unchanged", "status": status}, None
|
|
2665
|
+
added, err = self._respond(sid, name, kept, "fetched", status)
|
|
2666
|
+
if err:
|
|
2667
|
+
return None, err
|
|
2668
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2669
|
+
db.execute("UPDATE source_calls SET request = ?, provider = ?, error = NULL, file = ? "
|
|
2670
|
+
"WHERE source = ? AND name = ?",
|
|
2671
|
+
(json.dumps(request) if request else None, provider, name, sid, name))
|
|
2672
|
+
return {"name": name, "outcome": "fetched", "provider": provider, "request": request,
|
|
2673
|
+
"status": status, "bytes": len(kept), **added}, None
|
|
2674
|
+
|
|
2675
|
+
# ---- an item's responses (#587) -----------------------------------------
|
|
2676
|
+
#
|
|
2677
|
+
# A Call or a record keeps its responses: each fetch, import or edit is
|
|
2678
|
+
# one, kept with its bytes, and the newest -- or the one Restore picks --
|
|
2679
|
+
# is current: its bytes are the file a run grades. Nothing forks; a copy
|
|
2680
|
+
# (Clone) is a new item with a response of its own.
|
|
2681
|
+
|
|
2682
|
+
# How many responses an item keeps: the oldest that is neither current nor
|
|
2683
|
+
# pinned by a pipeline (#596) goes.
|
|
2684
|
+
RESPONSES_KEPT = 20
|
|
2685
|
+
# The kind of response a Call's provenance row says its file was.
|
|
2686
|
+
ORIGIN_KINDS = {"snapshot": "fetched", "run": "fetched", "edited": "edited",
|
|
2687
|
+
"upload": "uploaded", "hand": "added", "copy": "copied"}
|
|
2688
|
+
RESPONSE_KINDS = ("fetched", "recorded", "uploaded", "added", "edited", "copied")
|
|
2689
|
+
|
|
2690
|
+
def _described(self, sid, name, data):
|
|
2691
|
+
"""What an item's bytes were before it kept responses: from its Call's
|
|
2692
|
+
provenance row, else from its kind's `describe` hook (a record says
|
|
2693
|
+
whether production recorded it), else an upload, as of the file."""
|
|
2551
2694
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2695
|
+
row = db.execute("SELECT origin, at, status FROM source_calls WHERE source = ? AND name = ?",
|
|
2696
|
+
(sid, name)).fetchone()
|
|
2697
|
+
f = db.execute("SELECT at FROM source_files WHERE source = ? AND name = ?",
|
|
2698
|
+
(sid, name)).fetchone()
|
|
2699
|
+
if row is not None:
|
|
2700
|
+
return {"kind": self.ORIGIN_KINDS.get(row[0], "uploaded"), "at": row[1], "status": row[2]}
|
|
2701
|
+
describe = (self.type_of(sid) or {}).get("describe")
|
|
2702
|
+
got = describe(data) if describe else None
|
|
2703
|
+
out = {"kind": "uploaded", "at": f[0] if f else None, "status": None}
|
|
2704
|
+
if isinstance(got, dict):
|
|
2705
|
+
out.update({k: v for k, v in got.items() if v is not None})
|
|
2706
|
+
return out
|
|
2707
|
+
|
|
2708
|
+
def _respond(self, sid, name, data, kind, status):
|
|
2709
|
+
"""[data] as [name]'s new current response: the file rewritten in
|
|
2710
|
+
place, as an edit always was, and the row added. An item with no rows
|
|
2711
|
+
yet first gains one for the bytes it held. Returns ({n, was}, None),
|
|
2712
|
+
or (None, error)."""
|
|
2713
|
+
path = self.file_path(sid, name)
|
|
2714
|
+
if path is None or not path.is_file():
|
|
2715
|
+
return None, (404, f"{name} is not in that Source")
|
|
2716
|
+
old = path.read_bytes()
|
|
2717
|
+
first = None
|
|
2718
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2719
|
+
seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
|
|
2720
|
+
(sid, name)).fetchone() is not None
|
|
2721
|
+
if not seeded:
|
|
2722
|
+
first = self._described(sid, name, old)
|
|
2723
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
2724
|
+
folder = self.dir / sid
|
|
2725
|
+
with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
|
|
2726
|
+
f.write(data)
|
|
2727
|
+
tmp = f.name
|
|
2728
|
+
try:
|
|
2729
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2730
|
+
total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
|
|
2731
|
+
if total + len(data) - len(old) > MAX_SOURCE:
|
|
2732
|
+
raise _Refused((413, "that would take the source past its cap"))
|
|
2733
|
+
n, was = self._response_rows(db, sid, name, old, first, data, kind, status, now)
|
|
2734
|
+
db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
|
|
2735
|
+
(len(data), sid, name))
|
|
2736
|
+
db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
|
|
2737
|
+
except _Refused as no:
|
|
2738
|
+
os.unlink(tmp)
|
|
2739
|
+
return None, no.err
|
|
2740
|
+
os.replace(tmp, folder / name)
|
|
2741
|
+
return {"n": n, "was": was}, None
|
|
2742
|
+
|
|
2743
|
+
def _response_rows(self, db, sid, name, old, first, data, kind, status, now):
|
|
2744
|
+
"""The rows behind _respond and an edit, inside the caller's
|
|
2745
|
+
transaction: the bytes [name] held as its first response when it had
|
|
2746
|
+
none ([first] describes them), [data] as the new current one, the
|
|
2747
|
+
oldest past RESPONSES_KEPT dropped -- never one a pipeline pins
|
|
2748
|
+
(#596), which does not count against the rest. Returns (n, was)."""
|
|
2749
|
+
if first is not None:
|
|
2750
|
+
db.execute("INSERT OR IGNORE INTO source_responses (source, name, n, at, kind, status, "
|
|
2751
|
+
"body, current) VALUES (?, ?, 1, ?, ?, ?, ?, 1)",
|
|
2752
|
+
(sid, name, first["at"] or now, first["kind"], first["status"], old))
|
|
2753
|
+
was = db.execute("SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 1",
|
|
2754
|
+
(sid, name)).fetchone()
|
|
2755
|
+
n = db.execute("SELECT COALESCE(MAX(n), 0) + 1 FROM source_responses WHERE source = ? "
|
|
2756
|
+
"AND name = ?", (sid, name)).fetchone()[0]
|
|
2757
|
+
db.execute("UPDATE source_responses SET current = 0 WHERE source = ? AND name = ?", (sid, name))
|
|
2758
|
+
db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
|
|
2759
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 1)", (sid, name, n, now, kind, status, data))
|
|
2760
|
+
pinned = response_pins(db, sid, name)
|
|
2761
|
+
loose = [r for (r,) in db.execute(
|
|
2762
|
+
"SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 0 "
|
|
2763
|
+
"ORDER BY n DESC", (sid, name)).fetchall() if r not in pinned]
|
|
2764
|
+
for gone in loose[self.RESPONSES_KEPT - 1:]:
|
|
2765
|
+
db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?", (sid, name, gone))
|
|
2766
|
+
db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?", (status, sid, name))
|
|
2767
|
+
return n, (was[0] if was else None)
|
|
2768
|
+
|
|
2769
|
+
def pinned_body(self, sid, name, n):
|
|
2770
|
+
"""The bytes of [name]'s response [n] a run's pin names (#596), or
|
|
2771
|
+
None when the response is gone. `n` 0 is an item that kept one
|
|
2772
|
+
response and no rows: its file, or once it gained rows, the first,
|
|
2773
|
+
which holds those bytes."""
|
|
2774
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2775
|
+
row = db.execute("SELECT body FROM source_responses WHERE source = ? AND name = ? AND n = ?",
|
|
2776
|
+
(sid, name, n or 1)).fetchone()
|
|
2777
|
+
seeded = row is not None or db.execute(
|
|
2778
|
+
"SELECT 1 FROM source_responses WHERE source = ? AND name = ?", (sid, name)).fetchone() is not None
|
|
2779
|
+
if row is not None:
|
|
2780
|
+
return bytes(row[0])
|
|
2781
|
+
if n == 0 and not seeded:
|
|
2782
|
+
path = self.file_path(sid, name)
|
|
2783
|
+
return path.read_bytes() if path is not None and path.is_file() else None
|
|
2784
|
+
return None
|
|
2785
|
+
|
|
2786
|
+
def responses(self, sid):
|
|
2787
|
+
"""Every item's responses, newest first, by name: {n, at, kind,
|
|
2788
|
+
status, current} -- no bytes. An item that has kept one response has
|
|
2789
|
+
no rows, and reads as that one, `n` 0, described as it was made. None
|
|
2790
|
+
for no such Source."""
|
|
2791
|
+
if self.get(sid) is None:
|
|
2792
|
+
return None
|
|
2793
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2794
|
+
names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
|
|
2795
|
+
rows = db.execute("SELECT name, n, at, kind, status, current FROM source_responses "
|
|
2796
|
+
"WHERE source = ? ORDER BY n DESC", (sid,)).fetchall()
|
|
2797
|
+
out = {}
|
|
2798
|
+
for name, n, at, kind, status, current in rows:
|
|
2799
|
+
out.setdefault(name, []).append({"n": n, "at": at, "kind": kind, "status": status,
|
|
2800
|
+
"current": bool(current)})
|
|
2801
|
+
for name in names:
|
|
2802
|
+
if name not in out:
|
|
2803
|
+
path = self.file_path(sid, name)
|
|
2804
|
+
data = path.read_bytes() if path is not None and path.is_file() else b""
|
|
2805
|
+
out[name] = [{"n": 0, **self._described(sid, name, data), "current": True}]
|
|
2806
|
+
return out
|
|
2807
|
+
|
|
2808
|
+
def restore_response(self, sid, name, n, drop=None):
|
|
2809
|
+
"""Makes [name]'s response [n] current: its bytes the file again, the
|
|
2810
|
+
response it kept, not a new one. [drop] removes a response outright as
|
|
2811
|
+
well -- Undo of a fetch or an edit takes back the one it added. Returns
|
|
2812
|
+
({name, n}, None), or (None, error)."""
|
|
2813
|
+
entry, err = self._calls_entry(sid)
|
|
2814
|
+
if err:
|
|
2815
|
+
return None, err
|
|
2816
|
+
path = self.file_path(sid, name)
|
|
2817
|
+
if path is None or not path.is_file():
|
|
2818
|
+
return None, (404, f"{name} is not in that Source")
|
|
2819
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2820
|
+
row = db.execute("SELECT body, status FROM source_responses WHERE source = ? AND name = ? AND n = ?",
|
|
2821
|
+
(sid, name, n)).fetchone()
|
|
2822
|
+
if row is None:
|
|
2823
|
+
return None, (404, f"{name} has no such response")
|
|
2824
|
+
data, status = bytes(row[0]), row[1]
|
|
2825
|
+
old = path.read_bytes()
|
|
2826
|
+
folder = self.dir / sid
|
|
2827
|
+
with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
|
|
2828
|
+
f.write(data)
|
|
2829
|
+
tmp = f.name
|
|
2830
|
+
try:
|
|
2831
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2832
|
+
total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
|
|
2833
|
+
if total + len(data) - len(old) > MAX_SOURCE:
|
|
2834
|
+
raise _Refused((413, "that would take the source past its cap"))
|
|
2835
|
+
db.execute("UPDATE source_responses SET current = (n = ?) WHERE source = ? AND name = ?",
|
|
2836
|
+
(n, sid, name))
|
|
2837
|
+
if drop is not None and drop != n:
|
|
2838
|
+
db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?",
|
|
2839
|
+
(sid, name, drop))
|
|
2840
|
+
db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
|
|
2841
|
+
(len(data), sid, name))
|
|
2842
|
+
db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
|
|
2843
|
+
db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?",
|
|
2844
|
+
(status, sid, name))
|
|
2845
|
+
except _Refused as no:
|
|
2846
|
+
os.unlink(tmp)
|
|
2847
|
+
return None, no.err
|
|
2848
|
+
os.replace(tmp, folder / name)
|
|
2849
|
+
return {"name": name, "n": n}, None
|
|
2850
|
+
|
|
2851
|
+
def key_held(self, sid, key, value):
|
|
2852
|
+
"""The name of the file of [sid] whose [key] (its kind's `key` hook)
|
|
2853
|
+
is [value], or None: a record from a run already held is not kept a
|
|
2854
|
+
second time, however it was edited since."""
|
|
2855
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2856
|
+
names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
|
|
2857
|
+
for name in names:
|
|
2858
|
+
path = self.file_path(sid, name)
|
|
2859
|
+
try:
|
|
2860
|
+
if path is not None and key(path.read_bytes()) == value:
|
|
2861
|
+
return name
|
|
2862
|
+
except OSError:
|
|
2863
|
+
continue
|
|
2864
|
+
return None
|
|
2561
2865
|
|
|
2562
2866
|
# ---- a Call opened in the Call dialog: edited, renamed, cloned (#438) ---
|
|
2563
2867
|
#
|
|
@@ -2567,7 +2871,7 @@ class Sources:
|
|
|
2567
2871
|
# the rest redacted. A File Library has no hook and no Calls.
|
|
2568
2872
|
|
|
2569
2873
|
# The origins a dynamic Call's provenance row may be put back to (Undo).
|
|
2570
|
-
CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited")
|
|
2874
|
+
CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited", "copy")
|
|
2571
2875
|
|
|
2572
2876
|
def _calls_entry(self, sid):
|
|
2573
2877
|
"""The enforcement entry of a user Source whose files are Calls, or
|
|
@@ -2595,11 +2899,13 @@ class Sources:
|
|
|
2595
2899
|
the Source's `take` hook as an upload does. A rename keeps the
|
|
2596
2900
|
extension and refuses a name in use, or a Call that a dataset's cases
|
|
2597
2901
|
name ([named_by] answers which datasets do), since a case finds its
|
|
2598
|
-
item by name.
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
Returns ({name, origin, bytes}
|
|
2602
|
-
|
|
2902
|
+
item by name. Changed text is a new response, `edited` -- a rename
|
|
2903
|
+
alone is no edit -- and the item's responses follow a rename. [origin]
|
|
2904
|
+
puts back a dynamic Call's provenance (an older page's Undo). Nothing
|
|
2905
|
+
is written unless all of it passes. Returns ({name, origin, bytes} and,
|
|
2906
|
+
for an edit, the response it added `n` and the one it replaced `was`,
|
|
2907
|
+
None), or (None, (code, sentence)) -- (code, sentence, "name") when
|
|
2908
|
+
the Name field is what is wrong."""
|
|
2603
2909
|
entry, err = self._calls_entry(sid)
|
|
2604
2910
|
if err:
|
|
2605
2911
|
return None, err
|
|
@@ -2636,12 +2942,26 @@ class Sources:
|
|
|
2636
2942
|
if take_err:
|
|
2637
2943
|
return None, take_err
|
|
2638
2944
|
changed = data != old
|
|
2945
|
+
first = status = None
|
|
2946
|
+
if changed:
|
|
2947
|
+
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
2948
|
+
seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
|
|
2949
|
+
(sid, name)).fetchone() is not None
|
|
2950
|
+
held = db.execute("SELECT status FROM source_calls WHERE source = ? AND name = ?",
|
|
2951
|
+
(sid, name)).fetchone()
|
|
2952
|
+
if not seeded:
|
|
2953
|
+
first = self._described(sid, name, old)
|
|
2954
|
+
# An edited reply says its own status (a record's); an edited Call
|
|
2955
|
+
# keeps the status of the fetch it was.
|
|
2956
|
+
described = entry["describe"](data) if entry.get("describe") else None
|
|
2957
|
+
status = (described or {}).get("status") if described is not None else (held[0] if held else None)
|
|
2639
2958
|
folder = self.dir / sid
|
|
2640
2959
|
tmp = None
|
|
2641
2960
|
if changed or new != name:
|
|
2642
2961
|
with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
|
|
2643
2962
|
f.write(data)
|
|
2644
2963
|
tmp = f.name
|
|
2964
|
+
added = {}
|
|
2645
2965
|
try:
|
|
2646
2966
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2647
2967
|
if new != name and new in self._names_held(db, sid):
|
|
@@ -2652,13 +2972,18 @@ class Sources:
|
|
|
2652
2972
|
db.execute("UPDATE source_files SET name = ?, bytes = ? WHERE source = ? AND name = ?",
|
|
2653
2973
|
(new, len(data), sid, name))
|
|
2654
2974
|
db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
|
|
2975
|
+
db.execute("UPDATE source_responses SET name = ? WHERE source = ? AND name = ?", (new, sid, name))
|
|
2655
2976
|
row = db.execute("SELECT origin FROM source_calls WHERE source = ? AND name = ?",
|
|
2656
2977
|
(sid, name)).fetchone()
|
|
2657
2978
|
now = None
|
|
2658
2979
|
if row is not None:
|
|
2659
|
-
now = origin or
|
|
2980
|
+
now = origin or row[0]
|
|
2660
2981
|
db.execute("UPDATE source_calls SET name = ?, file = ?, origin = ? "
|
|
2661
2982
|
"WHERE source = ? AND name = ?", (new, new, now, sid, name))
|
|
2983
|
+
if changed:
|
|
2984
|
+
n, was = self._response_rows(db, sid, new, old, first, data, "edited", status,
|
|
2985
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
|
2986
|
+
added = {"n": n, "was": was}
|
|
2662
2987
|
except _Refused as no:
|
|
2663
2988
|
if tmp:
|
|
2664
2989
|
os.unlink(tmp)
|
|
@@ -2667,13 +2992,14 @@ class Sources:
|
|
|
2667
2992
|
os.replace(tmp, folder / new)
|
|
2668
2993
|
if new != name:
|
|
2669
2994
|
(folder / name).unlink(missing_ok=True)
|
|
2670
|
-
return {"name": new, "origin": now, "bytes": len(data)}, None
|
|
2995
|
+
return {"name": new, "origin": now, "bytes": len(data), **added}, None
|
|
2671
2996
|
|
|
2672
2997
|
def clone_call(self, sid, name):
|
|
2673
|
-
"""A copy of one Call under `<stem>-copy` (then `-copy-2`, ...)
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2998
|
+
"""A copy of one Call under `<stem>-copy` (then `-copy-2`, ...): its
|
|
2999
|
+
current bytes as the copy's one response, `copied`, and for a dynamic
|
|
3000
|
+
Call its request and provider, its origin `copy` -- so a fetch never
|
|
3001
|
+
reaches it and a live run keeps it as saved (#587). Returns ({name},
|
|
3002
|
+
None), or (None, error)."""
|
|
2677
3003
|
entry, err = self._calls_entry(sid)
|
|
2678
3004
|
if err:
|
|
2679
3005
|
return None, err
|
|
@@ -2690,13 +3016,18 @@ class Sources:
|
|
|
2690
3016
|
if copy not in held:
|
|
2691
3017
|
break
|
|
2692
3018
|
n += 1
|
|
2693
|
-
|
|
3019
|
+
data = path.read_bytes()
|
|
3020
|
+
stored, err = self.add_file(sid, copy, data)
|
|
2694
3021
|
if err:
|
|
2695
3022
|
return None, err
|
|
3023
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
2696
3024
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
2697
3025
|
db.execute("INSERT INTO source_calls (source, name, origin, at, provider, request, status, error, file) "
|
|
2698
|
-
"SELECT source, ?,
|
|
2699
|
-
"FROM source_calls WHERE source = ? AND name = ?", (stored, stored, sid, name))
|
|
3026
|
+
"SELECT source, ?, 'copy', ?, provider, request, status, error, ? "
|
|
3027
|
+
"FROM source_calls WHERE source = ? AND name = ?", (stored, now, stored, sid, name))
|
|
3028
|
+
# A copy starts its own responses: a fetch never reaches it.
|
|
3029
|
+
db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
|
|
3030
|
+
"VALUES (?, ?, 1, ?, 'copied', NULL, ?, 1)", (sid, stored, now, data))
|
|
2700
3031
|
return {"name": stored}, None
|
|
2701
3032
|
|
|
2702
3033
|
def remove_calls(self, sid, names):
|
|
@@ -2792,6 +3123,14 @@ class Sources:
|
|
|
2792
3123
|
"VALUES (?, ?, ?, ?)",
|
|
2793
3124
|
(sid, f["name"], f.get("bytes", 0), f.get("at", "")))
|
|
2794
3125
|
db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (total, sid))
|
|
3126
|
+
# An entry from before calls and responses were trashed has neither.
|
|
3127
|
+
for c in m.get("calls") or []:
|
|
3128
|
+
db.execute("INSERT OR REPLACE INTO source_calls (source, name, origin, at, provider, "
|
|
3129
|
+
"request, status, error, file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (sid, *c))
|
|
3130
|
+
for r in m.get("responses") or []:
|
|
3131
|
+
db.execute("INSERT OR REPLACE INTO source_responses (source, name, n, at, kind, status, "
|
|
3132
|
+
"body, current) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
3133
|
+
(sid, r[0], r[1], r[2], r[3], r[4], base64.b64decode(r[5]), r[6]))
|
|
2795
3134
|
th = self.dir / sid / ".thumbs"
|
|
2796
3135
|
th.mkdir(parents=True, exist_ok=True)
|
|
2797
3136
|
for f in files:
|
|
@@ -3457,6 +3796,26 @@ def pins_in(workflows) -> set:
|
|
|
3457
3796
|
return out
|
|
3458
3797
|
|
|
3459
3798
|
|
|
3799
|
+
def response_pins(db, sid, name) -> set:
|
|
3800
|
+
"""Every response `n` of Source [sid]'s item [name] a stored pipeline pins
|
|
3801
|
+
(#596), across every workspace's promptlab.workflows document, read in
|
|
3802
|
+
the caller's transaction. A pin of `n` 0 -- an item before it kept rows --
|
|
3803
|
+
pins the first row, which holds those bytes."""
|
|
3804
|
+
out = set()
|
|
3805
|
+
for (body,) in db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows'"):
|
|
3806
|
+
try:
|
|
3807
|
+
listed = (json.loads(body) or {}).get("list") if body else None
|
|
3808
|
+
except (ValueError, TypeError, AttributeError):
|
|
3809
|
+
continue
|
|
3810
|
+
for w in listed if isinstance(listed, list) else []:
|
|
3811
|
+
c = content_of(w.get("work")) if isinstance(w, dict) else None
|
|
3812
|
+
pin = c.get("pin") if isinstance(c, dict) else None
|
|
3813
|
+
if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
|
|
3814
|
+
and pin.get("name") == name and type(pin.get("n")) is int):
|
|
3815
|
+
out.add(pin["n"] or 1)
|
|
3816
|
+
return out
|
|
3817
|
+
|
|
3818
|
+
|
|
3460
3819
|
def group_key(ref) -> str:
|
|
3461
3820
|
"""The key a run keeps a group's body under: `<id>@<n>`, or the id alone
|
|
3462
3821
|
for a reference from before the lab numbered versions."""
|
|
@@ -4411,8 +4770,8 @@ class Datasets:
|
|
|
4411
4770
|
|
|
4412
4771
|
def save(self, did, version, body):
|
|
4413
4772
|
"""The body, written at [version] -- the one it began from. Returns
|
|
4414
|
-
({version}, None), or (None, error); a stale version's error
|
|
4415
|
-
the current row."""
|
|
4773
|
+
({version, versions}, None), or (None, error); a stale version's error
|
|
4774
|
+
carries the current row."""
|
|
4416
4775
|
if type(version) is not int:
|
|
4417
4776
|
return None, (400, "a save names the version it began from")
|
|
4418
4777
|
# A body of an earlier version -- from a page loaded before this one --
|
|
@@ -4431,7 +4790,9 @@ class Datasets:
|
|
|
4431
4790
|
self._keep(db, r, text)
|
|
4432
4791
|
db.execute("UPDATE datasets SET body = ?, version = ?, updated_at = ? WHERE id = ?",
|
|
4433
4792
|
(text, version + 1, self._now(), did))
|
|
4434
|
-
|
|
4793
|
+
# The save may have cut a version: a link pins the newest (#547).
|
|
4794
|
+
newest = self._head(db, did)["n"]
|
|
4795
|
+
return {"version": version + 1, "versions": newest}, None
|
|
4435
4796
|
|
|
4436
4797
|
def remove(self, did):
|
|
4437
4798
|
"""Into the trash, at once; Undo restores it. Returns (token, None)."""
|
|
@@ -4863,8 +5224,8 @@ class FilterSets:
|
|
|
4863
5224
|
|
|
4864
5225
|
def save(self, fid, version, body):
|
|
4865
5226
|
"""The body, written at [version] -- the one it began from. Returns
|
|
4866
|
-
({version}, None), or (None, error); a stale version's error
|
|
4867
|
-
the current row."""
|
|
5227
|
+
({version, versions}, None), or (None, error); a stale version's error
|
|
5228
|
+
carries the current row."""
|
|
4868
5229
|
if type(version) is not int:
|
|
4869
5230
|
return None, (400, "a save names the version it began from")
|
|
4870
5231
|
body = upgrade_filter_set_body(body)
|
|
@@ -4881,7 +5242,9 @@ class FilterSets:
|
|
|
4881
5242
|
self._keep(db, r, text)
|
|
4882
5243
|
db.execute("UPDATE filter_sets SET body = ?, version = ?, updated_at = ? WHERE id = ?",
|
|
4883
5244
|
(text, version + 1, self._now(), fid))
|
|
4884
|
-
|
|
5245
|
+
# The save may have cut a version: a link pins the newest (#547).
|
|
5246
|
+
newest = self._head(db, fid)["n"]
|
|
5247
|
+
return {"version": version + 1, "versions": newest}, None
|
|
4885
5248
|
|
|
4886
5249
|
def remove(self, fid):
|
|
4887
5250
|
"""Into the trash, at once; Undo restores it. Returns (token, None)."""
|
|
@@ -5141,6 +5504,54 @@ def demo_pack() -> bytes:
|
|
|
5141
5504
|
return buf.getvalue()
|
|
5142
5505
|
|
|
5143
5506
|
|
|
5507
|
+
# The connection types a prompt cannot be asked of (evals-core.ts: their
|
|
5508
|
+
# `answers` lacks "prompt", or they answer from the item and are no model).
|
|
5509
|
+
NO_WORDS = {"echo", "recorded", "http"}
|
|
5510
|
+
|
|
5511
|
+
|
|
5512
|
+
# The built-in connection types that read a document whole, and the suffixes
|
|
5513
|
+
# that are one: evals-core.ts's `documents: true` and DOCUMENT_TYPES (#558).
|
|
5514
|
+
DOCUMENT_CONNECTIONS = ("openai-compatible", "anthropic")
|
|
5515
|
+
DOCUMENT_SUFFIXES = (".pdf",)
|
|
5516
|
+
|
|
5517
|
+
|
|
5518
|
+
def pack_connection(ws, documents=False):
|
|
5519
|
+
"""The AI Connection a pack's target answers on where it names one this
|
|
5520
|
+
lab lacks (#550): the lab's grader, or else the first connection that is
|
|
5521
|
+
asked words, either shared with workspace [ws]; None where there is none,
|
|
5522
|
+
so the target keeps its placeholder and the Run bar says to add one. A
|
|
5523
|
+
pipeline that sends [documents] looks first among the connections that
|
|
5524
|
+
read one (#560), so the Run bar has nothing to refuse where the lab has
|
|
5525
|
+
one."""
|
|
5526
|
+
body = ((STORE.all().get("promptlab.connections") or {}).get("body") or {}) if STORE is not None else {}
|
|
5527
|
+
words = [c for c in body.get("list") or []
|
|
5528
|
+
if isinstance(c, dict) and isinstance(c.get("id"), str) and c["id"]
|
|
5529
|
+
and c.get("type") not in NO_WORDS and STORE.shared("profile", c["id"], ws)]
|
|
5530
|
+
if documents:
|
|
5531
|
+
words = [c for c in words if c.get("type") in DOCUMENT_CONNECTIONS] or words
|
|
5532
|
+
return next((c for c in words if c["id"] == body.get("grader")), words[0] if words else None)
|
|
5533
|
+
|
|
5534
|
+
|
|
5535
|
+
def repoint_targets(doc: dict, ws, documents=False) -> None:
|
|
5536
|
+
"""A pack pipeline's targets that name a connection this lab does not have,
|
|
5537
|
+
pointed at the one pack_connection picks, its profile dropped with it."""
|
|
5538
|
+
targets = doc.get("targets")
|
|
5539
|
+
if not isinstance(targets, list) or STORE is None:
|
|
5540
|
+
return
|
|
5541
|
+
have = {c.get("id") for c in ((STORE.all().get("promptlab.connections") or {}).get("body") or {}).get("list") or []
|
|
5542
|
+
if isinstance(c, dict)}
|
|
5543
|
+
pick = None
|
|
5544
|
+
for t in targets:
|
|
5545
|
+
ref = t.get("connection") if isinstance(t, dict) else None
|
|
5546
|
+
if not isinstance(ref, dict) or ref.get("id") in have:
|
|
5547
|
+
continue
|
|
5548
|
+
pick = pick or pack_connection(ws, documents)
|
|
5549
|
+
if pick is None:
|
|
5550
|
+
return
|
|
5551
|
+
t["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
|
|
5552
|
+
t["profile"] = None
|
|
5553
|
+
|
|
5554
|
+
|
|
5144
5555
|
def pack_requires_problem(manifest: dict, plugins: set) -> str:
|
|
5145
5556
|
"""Why this lab cannot take a pack its manifest describes, or ""."""
|
|
5146
5557
|
req = manifest.get("requires") or {}
|
|
@@ -5529,10 +5940,15 @@ class Packs:
|
|
|
5529
5940
|
ref.clear()
|
|
5530
5941
|
ref.update({"id": did, "name": DATASETS.get(did)["name"]})
|
|
5531
5942
|
content = content_of(doc)
|
|
5943
|
+
documents = False
|
|
5532
5944
|
if isinstance(content, dict) and isinstance(content.get("ref"), dict):
|
|
5533
|
-
|
|
5945
|
+
ref = content["ref"]
|
|
5946
|
+
files = pack["sources"].get(ref.get("id")) or pack["sources"].get(ref.get("name")) or []
|
|
5947
|
+
documents = any(Path(f).suffix.lower() in DOCUMENT_SUFFIXES for f, _ in files)
|
|
5948
|
+
sid = src_ids.get(ref.get("id")) or src_ids.get(ref.get("name"))
|
|
5534
5949
|
if sid:
|
|
5535
5950
|
content["ref"] = {"id": sid, "name": SOURCES.get(sid)["name"]}
|
|
5951
|
+
repoint_targets(doc, ws, documents)
|
|
5536
5952
|
new_work.append((key, doc))
|
|
5537
5953
|
def place(body):
|
|
5538
5954
|
body.setdefault("list", [])
|
|
@@ -6459,7 +6875,8 @@ class Queue:
|
|
|
6459
6875
|
# a dynamic Source graded live is fetched at submit and kept here,
|
|
6460
6876
|
# by item name, so the worker stays a pure dir-reader -- _materialise
|
|
6461
6877
|
# writes these into the run dir rather than copying a Source file. A
|
|
6462
|
-
#
|
|
6878
|
+
# run over a pinned response (#596) keeps that response's bytes here
|
|
6879
|
+
# too; a snapshot run keeps none and reads the Source's stored Calls.
|
|
6463
6880
|
if "produced" not in cols:
|
|
6464
6881
|
db.execute("ALTER TABLE queue ADD COLUMN produced TEXT")
|
|
6465
6882
|
# The workspace a run belongs to (docs/workspaces.md): History is
|
|
@@ -6643,7 +7060,8 @@ class Queue:
|
|
|
6643
7060
|
def produced(self, rid):
|
|
6644
7061
|
"""A live dynamic run's produced item bytes, by item name, or None for
|
|
6645
7062
|
a run that produced none (a snapshot run, or any non-dynamic run). The
|
|
6646
|
-
values are the text the server fetched at submit,
|
|
7063
|
+
values are the text the server fetched at submit, or the pinned
|
|
7064
|
+
response's (#596), kept so a start, a
|
|
6647
7065
|
resume and a re-run grade the same bytes (docs/dynamic-sources.md)."""
|
|
6648
7066
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
6649
7067
|
r = db.execute("SELECT produced FROM queue WHERE id = ?", (rid,)).fetchone()
|
|
@@ -7005,7 +7423,8 @@ class Queue:
|
|
|
7005
7423
|
# A live dynamic run carries its items' bytes with the row, fetched at
|
|
7006
7424
|
# submit (docs/dynamic-sources.md): they are written into the run dir
|
|
7007
7425
|
# rather than copied from a Source, so the worker still just reads a
|
|
7008
|
-
# directory of files. A
|
|
7426
|
+
# directory of files. A pinned run (#596) carries its response's bytes
|
|
7427
|
+
# the same way. A snapshot run has none and reads the Source.
|
|
7009
7428
|
live = self.produced(run["id"])
|
|
7010
7429
|
rundir = self.dir / run["id"]
|
|
7011
7430
|
files_dir = rundir / "files"
|
|
@@ -7473,10 +7892,12 @@ def worker_destinations(run: dict):
|
|
|
7473
7892
|
# (answer_key), each entry naming the connection whose key it takes (#445).
|
|
7474
7893
|
# 17: an HTTP Request target has no `api` of its own; its job's flow step's
|
|
7475
7894
|
# is the one its body is read by (#477).
|
|
7476
|
-
|
|
7895
|
+
# 18: a dynamic Source's content graded as saved pins one response, `pin:
|
|
7896
|
+
# {name, n}`, which a fetch, an edit or a Restore never moves (#596).
|
|
7897
|
+
PIPELINE_VERSION = 18
|
|
7477
7898
|
# What a stored run may be: the current version, and the ones evals-core.ts's
|
|
7478
7899
|
# upgradePipeline reads. A new submission is upgraded to the current one.
|
|
7479
|
-
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
|
|
7900
|
+
READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18)
|
|
7480
7901
|
TARGET_CAP = 4
|
|
7481
7902
|
# Target steps whose words the Prompt library does not record as a use: they
|
|
7482
7903
|
# ask no model (evals-core.ts's STEP_TYPES.echo).
|
|
@@ -7518,10 +7939,12 @@ CONNECTION_TYPES = {
|
|
|
7518
7939
|
"presencePenalty", "dryMultiplier", "dryBase", "dryAllowedLength", "grammar",
|
|
7519
7940
|
"imagePosition", "cachePrompt", "thinking", "rejectLoops"),
|
|
7520
7941
|
}
|
|
7521
|
-
# Types that answer from the item and send no request (Echo, #97
|
|
7522
|
-
# address to reach, no key to carry, so the destination rules have
|
|
7523
|
-
# say about them. A
|
|
7524
|
-
|
|
7942
|
+
# Types that answer from the item and send no request (Echo, #97; Recorded,
|
|
7943
|
+
# #299): no address to reach, no key to carry, so the destination rules have
|
|
7944
|
+
# nothing to say about them. A target step stands in for each now, so a stored
|
|
7945
|
+
# row of one is never exported (lab_export). A plugin's type joins with
|
|
7946
|
+
# `"local": true`.
|
|
7947
|
+
LOCAL_CONNECTIONS = {"echo", "recorded"}
|
|
7525
7948
|
# The built-in types, kept apart so a plugin's can be taken away again.
|
|
7526
7949
|
BUILTIN_CONNECTION_TYPES = dict(CONNECTION_TYPES)
|
|
7527
7950
|
BUILTIN_LOCAL = set(LOCAL_CONNECTIONS)
|
|
@@ -7905,6 +8328,10 @@ def run_problems(run):
|
|
|
7905
8328
|
bad.append("a run over a Source needs its file list")
|
|
7906
8329
|
elif not isinstance(files, list) or not files or not all(isinstance(f, str) for f in files):
|
|
7907
8330
|
bad.append("a run over a Source needs its file list")
|
|
8331
|
+
pin = content.get("pin")
|
|
8332
|
+
if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
|
|
8333
|
+
and type(pin.get("n")) is int and pin["n"] >= 0):
|
|
8334
|
+
bad.append("a Source content's pin names a response as { name, n }")
|
|
7908
8335
|
elif kind == "text":
|
|
7909
8336
|
if not isinstance(content.get("text"), str) or not content["text"].strip():
|
|
7910
8337
|
bad.append("a run over text needs some text")
|
|
@@ -7967,6 +8394,11 @@ class NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
|
7967
8394
|
# -- a host nobody put on the list -- and the relay reported 200. An
|
|
7968
8395
|
# allow-list that an environment variable can route around is not one.
|
|
7969
8396
|
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects)
|
|
8397
|
+
# urllib's own "Python-urllib/3.x" is refused outright by many public APIs
|
|
8398
|
+
# behind Cloudflare (#512), so every outbound request names the lab instead --
|
|
8399
|
+
# honestly, never a browser. A request's own User-Agent, such as an API call
|
|
8400
|
+
# Source's Headers, wins: urllib adds this only where none is set.
|
|
8401
|
+
OPENER.addheaders = [("User-Agent", "evals-lab")]
|
|
7970
8402
|
|
|
7971
8403
|
|
|
7972
8404
|
class Handler(BaseHTTPRequestHandler):
|
|
@@ -8158,7 +8590,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8158
8590
|
# its own, and only in a lab with a store to keep it in.
|
|
8159
8591
|
return self._json(200, {"targetCap": TARGET_CAP, "microsoft": microsoft_config(),
|
|
8160
8592
|
"microsoftEditable": MICROSOFT is not None and not M365_CLIENT_ID,
|
|
8161
|
-
"dynamicProviders": dynamic_providers_config()
|
|
8593
|
+
"dynamicProviders": dynamic_providers_config(),
|
|
8594
|
+
"ollama": ollama_shown()})
|
|
8162
8595
|
if path == "/api/state":
|
|
8163
8596
|
if STORE is None:
|
|
8164
8597
|
return self._send(404, b"not found", "text/plain")
|
|
@@ -8965,16 +9398,45 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8965
9398
|
live = content.get("live")
|
|
8966
9399
|
if live is None:
|
|
8967
9400
|
live = str(cfg.get("live")).lower() in ("1", "true", "yes", "on") or not src.get("files")
|
|
8968
|
-
if live:
|
|
9401
|
+
if live and content["files"]:
|
|
9402
|
+
# Live data over held items (#587): each response the
|
|
9403
|
+
# document names is fetched again under its own name,
|
|
9404
|
+
# so its case applies; a copy, an upload or an item
|
|
9405
|
+
# written by hand is graded as saved.
|
|
9406
|
+
produced, err = self._produce_endpoints(sid, entry, cfg, content["files"])
|
|
9407
|
+
if err:
|
|
9408
|
+
return self._json(err[0], {"error": err[1]})
|
|
9409
|
+
content["live"] = True
|
|
9410
|
+
content["revs"] = {name: "live:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
9411
|
+
for name, text in produced.items()}
|
|
9412
|
+
elif live:
|
|
8969
9413
|
produced, name, err = self._produce_live(sid, entry, cfg)
|
|
8970
9414
|
if err:
|
|
8971
9415
|
return self._json(err[0], {"error": err[1]})
|
|
8972
9416
|
content["live"] = True
|
|
8973
|
-
|
|
9417
|
+
# The one capture, looped as a snapshot's files are
|
|
9418
|
+
# (evals-core.ts expand): Run first cannot cut below
|
|
9419
|
+
# one item, and Loop repeats it for reply variance (#513).
|
|
9420
|
+
loops = content.get("loops")
|
|
9421
|
+
content["files"] = [name] * (loops if type(loops) is int and loops >= 1 else 1)
|
|
8974
9422
|
content["revs"] = {name: "live:" + hashlib.sha256(
|
|
8975
9423
|
produced[name].encode("utf-8")).hexdigest()}
|
|
8976
9424
|
else:
|
|
8977
9425
|
content["live"] = False
|
|
9426
|
+
pin = content.get("pin")
|
|
9427
|
+
if not content["live"] and isinstance(pin, dict):
|
|
9428
|
+
# Saved response pins one (#596): the run grades that
|
|
9429
|
+
# response's bytes, kept with the row as a live run's are,
|
|
9430
|
+
# whatever a fetch, an edit or a Restore made current since.
|
|
9431
|
+
body = SOURCES.pinned_body(sid, pin["name"], pin["n"])
|
|
9432
|
+
if body is None:
|
|
9433
|
+
return self._json(400, {"error": f"the response this run grades, {pin['name']}, "
|
|
9434
|
+
"was removed: pick another"})
|
|
9435
|
+
text = body.decode("utf-8", errors="replace")
|
|
9436
|
+
produced = {pin["name"]: text}
|
|
9437
|
+
loops = content.get("loops")
|
|
9438
|
+
content["files"] = [pin["name"]] * (loops if type(loops) is int and loops >= 1 else 1)
|
|
9439
|
+
content["revs"] = {pin["name"]: "pin:" + hashlib.sha256(body).hexdigest()}
|
|
8978
9440
|
if produced is None:
|
|
8979
9441
|
# Snapshot (or a non-dynamic Source): pin the Source's own files
|
|
8980
9442
|
# at their current revision, the server's to say whatever the
|
|
@@ -9044,6 +9506,43 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9044
9506
|
return self._json(201, {"run": QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None,
|
|
9045
9507
|
produced=produced)})
|
|
9046
9508
|
|
|
9509
|
+
@staticmethod
|
|
9510
|
+
def _produce_endpoints(sid, entry, cfg, files):
|
|
9511
|
+
"""Every item [files] names, for a live run over a Source that holds
|
|
9512
|
+
some (#587): a response -- a Call fetched from a request -- fetched
|
|
9513
|
+
now from the request it was, under its own name; any other item (a
|
|
9514
|
+
copy, an upload, one by hand) its saved bytes. Returns ({name: text},
|
|
9515
|
+
None), or (None, (code, sentence)) -- a fetch that fails refuses the
|
|
9516
|
+
submit, naming the response."""
|
|
9517
|
+
rows = {c["name"]: c for c in SOURCES.calls(sid) or []}
|
|
9518
|
+
out = {}
|
|
9519
|
+
for name in dict.fromkeys(files):
|
|
9520
|
+
call = rows.get(name) or {}
|
|
9521
|
+
request = call.get("request") if isinstance(call.get("request"), dict) else {}
|
|
9522
|
+
if call.get("origin") in ("snapshot", "run", "edited") and request.get("url"):
|
|
9523
|
+
# The request it was: a response whose Source has moved on
|
|
9524
|
+
# since is still asked what it was asked.
|
|
9525
|
+
result = entry["produce"]({**cfg, **{k: request[k] for k in ("url", "method")
|
|
9526
|
+
if isinstance(request.get(k), str)}})
|
|
9527
|
+
if result.get("refused"):
|
|
9528
|
+
return None, result["refused"]
|
|
9529
|
+
if result.get("content") is None:
|
|
9530
|
+
return None, (502, f"the live fetch of {name} produced nothing: "
|
|
9531
|
+
+ (result.get("error") or "unknown"))
|
|
9532
|
+
kept, take_err = entry["take"](name, result["content"])
|
|
9533
|
+
if take_err:
|
|
9534
|
+
return None, take_err
|
|
9535
|
+
out[name] = kept.decode("utf-8")
|
|
9536
|
+
continue
|
|
9537
|
+
path = SOURCES.file_path(sid, name)
|
|
9538
|
+
if path is None or not path.is_file():
|
|
9539
|
+
return None, (400, f"{name!r} is not in that Source")
|
|
9540
|
+
try:
|
|
9541
|
+
out[name] = path.read_text("utf-8")
|
|
9542
|
+
except (OSError, UnicodeDecodeError):
|
|
9543
|
+
return None, (400, f"{name} is not text")
|
|
9544
|
+
return out, None
|
|
9545
|
+
|
|
9047
9546
|
@staticmethod
|
|
9048
9547
|
def _produce_live(sid, entry, cfg):
|
|
9049
9548
|
"""Produce a live dynamic Source's item now, for a run being queued:
|
|
@@ -9412,14 +9911,17 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9412
9911
|
parts = path.split("/")
|
|
9413
9912
|
if len(parts) == 3:
|
|
9414
9913
|
sources = SOURCES.list()
|
|
9415
|
-
return self._json(200, {"sources": sources, "cap": MAX_SOURCE
|
|
9416
|
-
"used": sum(s["bytes"] for s in sources if not s["system"])})
|
|
9914
|
+
return self._json(200, {"sources": sources, "cap": MAX_SOURCE})
|
|
9417
9915
|
if len(parts) == 4:
|
|
9418
9916
|
source = SOURCES.get(parts[3])
|
|
9419
9917
|
return self._json(200, source) if source else self._send(404, b"not found", "text/plain")
|
|
9420
9918
|
if len(parts) == 5 and parts[4] == "definition":
|
|
9421
9919
|
got = SOURCES.definition(parts[3])
|
|
9422
9920
|
return self._json(200, got) if got else self._send(404, b"not found", "text/plain")
|
|
9921
|
+
if len(parts) == 5 and parts[4] == "responses":
|
|
9922
|
+
held = SOURCES.responses(parts[3])
|
|
9923
|
+
return self._json(200, {"responses": held}) if held is not None \
|
|
9924
|
+
else self._send(404, b"not found", "text/plain")
|
|
9423
9925
|
if len(parts) == 5 and parts[4] == "calls":
|
|
9424
9926
|
calls = SOURCES.calls(parts[3])
|
|
9425
9927
|
return self._json(200, {"calls": calls}) if calls is not None \
|
|
@@ -9495,7 +9997,17 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9495
9997
|
if err:
|
|
9496
9998
|
code, message = err
|
|
9497
9999
|
return self._json(code, {"error": message})
|
|
9498
|
-
return self._json(201, {"call": call})
|
|
10000
|
+
return self._json(201 if call["outcome"] == "added" else 200, {"call": call})
|
|
10001
|
+
if len(parts) == 6 and parts[4] == "responses" and parts[5] == "restore":
|
|
10002
|
+
payload = self._payload() or {}
|
|
10003
|
+
name = source_name(payload.get("name"))
|
|
10004
|
+
n, drop = payload.get("n"), payload.get("drop")
|
|
10005
|
+
if name is None or type(n) is not int or not (drop is None or type(drop) is int):
|
|
10006
|
+
return self._json(400, {"error": "a restore is { name, n, drop? }"})
|
|
10007
|
+
got, err = SOURCES.restore_response(parts[3], name, n, drop)
|
|
10008
|
+
if err:
|
|
10009
|
+
return self._json(err[0], {"error": err[1]})
|
|
10010
|
+
return self._json(200, {"call": got})
|
|
9499
10011
|
if len(parts) == 6 and parts[4] == "calls" and parts[5] == "remove":
|
|
9500
10012
|
names = (self._payload() or {}).get("names")
|
|
9501
10013
|
if not isinstance(names, list) or not names:
|
|
@@ -9588,12 +10100,21 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9588
10100
|
if len(body) != length:
|
|
9589
10101
|
return self._json(400, {"error": "the body ended before Content-Length promised"})
|
|
9590
10102
|
# A type that checks what it takes (a flow's records) has it first.
|
|
9591
|
-
|
|
10103
|
+
entry = SOURCES.type_of(sid) or {}
|
|
10104
|
+
take = entry.get("take")
|
|
9592
10105
|
if take is not None:
|
|
9593
10106
|
body, err = take(name, body)
|
|
9594
10107
|
if err:
|
|
9595
10108
|
code, message = err
|
|
9596
10109
|
return self._json(code, {"error": message})
|
|
10110
|
+
# One a kind already holds by its key (a record of the same run's call,
|
|
10111
|
+
# edited or not) is not kept twice (#587).
|
|
10112
|
+
key = entry.get("key")
|
|
10113
|
+
value = key(body) if key is not None else None
|
|
10114
|
+
if value is not None:
|
|
10115
|
+
held = SOURCES.key_held(sid, key, value)
|
|
10116
|
+
if held is not None:
|
|
10117
|
+
return self._json(200, {"source": sid, "name": held, "unchanged": True})
|
|
9597
10118
|
stored, err = SOURCES.add_file(sid, name, body)
|
|
9598
10119
|
if err:
|
|
9599
10120
|
code, message = err
|
|
@@ -9688,11 +10209,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9688
10209
|
# key that comes is the connection's from then on, a rotated one included.
|
|
9689
10210
|
# A connection carries its share set (`shares`, workspace slugs and the
|
|
9690
10211
|
# sentinels; absent is All) from version 2: version 1 carried none, so an
|
|
9691
|
-
# import reads its connections as All.
|
|
10212
|
+
# import reads its connections as All. Version 3 carries (Default)'s settings
|
|
10213
|
+
# (`connectionProfileDefault`, #603), which the profiles store keeps beside
|
|
10214
|
+
# its list; an import sets them only on a lab whose (Default) sets nothing.
|
|
9692
10215
|
|
|
9693
10216
|
LAB_FORMAT = "evals-lab/lab"
|
|
9694
|
-
LAB_VERSIONS = (1, 2)
|
|
9695
|
-
LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "
|
|
10217
|
+
LAB_VERSIONS = (1, 2, 3)
|
|
10218
|
+
LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "connectionProfileDefault",
|
|
10219
|
+
"grader", "workspaces")
|
|
10220
|
+
# What a Connection Profile holds that is not a setting, so (Default) holds none.
|
|
10221
|
+
PROFILE_META = ("id", "name", "connection", "fromProfile")
|
|
9696
10222
|
|
|
9697
10223
|
|
|
9698
10224
|
def lab_export(only=None) -> dict:
|
|
@@ -9702,8 +10228,10 @@ def lab_export(only=None) -> dict:
|
|
|
9702
10228
|
the profiles over them (or over none) and the grader if it is one of them,
|
|
9703
10229
|
each share set narrowed to that workspace, the one the file names, and
|
|
9704
10230
|
*new kept where the connection has it. A connection shared only with
|
|
9705
|
-
workspaces in the trash is usable nowhere, and an empty share set would
|
|
9706
|
-
import as All, so it is left out, and out as the grader too.
|
|
10231
|
+
workspaces in the trash, or with none, is usable nowhere, and an empty share set would
|
|
10232
|
+
import as All, so it is left out, and out as the grader too. So is a
|
|
10233
|
+
connection of a local type (Echo): a target step stands in for it, and the
|
|
10234
|
+
lab it lands in offers that step already."""
|
|
9707
10235
|
body = (STORE.all().get("promptlab.connections") or {}).get("body") or {}
|
|
9708
10236
|
profiles = (STORE.all().get("promptlab.connectionProfiles") or {}).get("body") or {}
|
|
9709
10237
|
every = WORKSPACES.list()
|
|
@@ -9716,6 +10244,9 @@ def lab_export(only=None) -> dict:
|
|
|
9716
10244
|
for c in body.get("list") or []:
|
|
9717
10245
|
if not isinstance(c, dict) or (only and not STORE.shared("profile", str(c.get("id") or ""), spaces[0]["id"])):
|
|
9718
10246
|
continue
|
|
10247
|
+
if c.get("type") in LOCAL_CONNECTIONS:
|
|
10248
|
+
nowhere.add(c.get("id"))
|
|
10249
|
+
continue
|
|
9719
10250
|
entry = {k: v for k, v in c.items() if k not in ("key", "shares")}
|
|
9720
10251
|
held = rows.get(f"profile:{c.get('id')}")
|
|
9721
10252
|
if held and SHARE_ALL not in held:
|
|
@@ -9738,6 +10269,9 @@ def lab_export(only=None) -> dict:
|
|
|
9738
10269
|
if (isinstance(body.get("grader"), str) and body["grader"] and body["grader"] not in nowhere
|
|
9739
10270
|
and (not only or body["grader"] in kept)):
|
|
9740
10271
|
out["grader"] = body["grader"]
|
|
10272
|
+
# (Default) answers on any connection, so every workspace's file has it.
|
|
10273
|
+
if isinstance(profiles.get("default"), dict) and profiles["default"]:
|
|
10274
|
+
out["connectionProfileDefault"] = profiles["default"]
|
|
9741
10275
|
return out
|
|
9742
10276
|
|
|
9743
10277
|
|
|
@@ -9794,6 +10328,19 @@ def lab_problems(doc) -> str:
|
|
|
9794
10328
|
why = _keyless(p, what)
|
|
9795
10329
|
if why:
|
|
9796
10330
|
return why
|
|
10331
|
+
if "connectionProfileDefault" in doc:
|
|
10332
|
+
d = doc["connectionProfileDefault"]
|
|
10333
|
+
if doc["version"] < 3:
|
|
10334
|
+
return f"a lab file of version {doc['version']} holds no connectionProfileDefault"
|
|
10335
|
+
if not isinstance(d, dict):
|
|
10336
|
+
return "the connection profile (Default) is an object of settings"
|
|
10337
|
+
bad = next((k for k, v in d.items() if k in PROFILE_META or not isinstance(v, str)), None)
|
|
10338
|
+
if bad:
|
|
10339
|
+
return f"the connection profile (Default)'s {bad} is not a setting"
|
|
10340
|
+
# A profile's key is its connection's, and (Default) names none.
|
|
10341
|
+
held = next((k for k, v in d.items() if LOOKS_LIKE_A_KEY.match(k) and v), None)
|
|
10342
|
+
if held:
|
|
10343
|
+
return f"the connection profile (Default) holds a {held}; a lab file holds no key"
|
|
9797
10344
|
if "grader" in doc and not isinstance(doc["grader"], str):
|
|
9798
10345
|
return "the grader is not a connection's id"
|
|
9799
10346
|
seen = set()
|
|
@@ -9827,6 +10374,11 @@ def lab_problems(doc) -> str:
|
|
|
9827
10374
|
if not c["shares"]:
|
|
9828
10375
|
return (f"connection {c['id']}'s shares is empty; leave shares out for All, "
|
|
9829
10376
|
"or name a workspace or *new")
|
|
10377
|
+
# *none is the store's own mark for a set that lost its last workspace;
|
|
10378
|
+
# an export leaves such a connection out, so a file never carries it.
|
|
10379
|
+
if SHARE_NONE in c["shares"]:
|
|
10380
|
+
return (f"connection {c['id']}'s shares names *none; a lab file holds no connection "
|
|
10381
|
+
"shared with no workspace -- leave it out, or name a workspace or *new")
|
|
9830
10382
|
lost = [s for s in c["shares"] if s not in (SHARE_ALL, SHARE_NEW) and s not in known]
|
|
9831
10383
|
if lost:
|
|
9832
10384
|
return f"connection {c['id']} is shared with {', '.join(lost)}, which neither the file nor this lab has"
|
|
@@ -9887,15 +10439,17 @@ def lab_import(doc, keys) -> tuple:
|
|
|
9887
10439
|
profs.append(dict(p))
|
|
9888
10440
|
pchanged = True
|
|
9889
10441
|
lines.append(f"connection profile {p['name']} added")
|
|
10442
|
+
if doc.get("connectionProfileDefault") and not pbody.get("default"):
|
|
10443
|
+
pbody["default"] = dict(doc["connectionProfileDefault"])
|
|
10444
|
+
pchanged = True
|
|
10445
|
+
lines.append("connection profile (Default) set")
|
|
9890
10446
|
if doc.get("grader") and not body.get("grader"):
|
|
9891
10447
|
body["grader"] = doc["grader"]
|
|
9892
10448
|
changed = True
|
|
9893
10449
|
lines.append(f"grader set to {doc['grader']}")
|
|
9894
10450
|
# Only an added connection takes the file's share set: one the lab had
|
|
9895
10451
|
# keeps its own, as everything else it had does. Set before the write,
|
|
9896
|
-
# so the connection is never there and shared with All in between
|
|
9897
|
-
# failed write puts back what the rows were, since its ids never landed.
|
|
9898
|
-
before = STORE.shares()
|
|
10452
|
+
# so the connection is never there and shared with All in between.
|
|
9899
10453
|
for c in added:
|
|
9900
10454
|
if "shares" in c:
|
|
9901
10455
|
STORE.set_shares("profile", c["id"], [s if s in (SHARE_ALL, SHARE_NEW) else wid_of[s]
|
|
@@ -9912,9 +10466,15 @@ def lab_import(doc, keys) -> tuple:
|
|
|
9912
10466
|
ok, _ = STORE.write(writes, STORE.default_ws)
|
|
9913
10467
|
if ok:
|
|
9914
10468
|
break
|
|
10469
|
+
# A failed write takes back the rows of each id that still never
|
|
10470
|
+
# landed, as no rows at all (an empty set is a delete, never *none).
|
|
10471
|
+
# One another writer landed in between keeps the rows it has now:
|
|
10472
|
+
# taking them would leave it shared with All.
|
|
10473
|
+
landed = {x.get("id") for x in ((STORE.all().get("promptlab.connections") or {}).get("body")
|
|
10474
|
+
or {}).get("list") or [] if isinstance(x, dict)}
|
|
9915
10475
|
for c in added:
|
|
9916
|
-
if "shares" in c:
|
|
9917
|
-
STORE.set_shares("profile", c["id"],
|
|
10476
|
+
if "shares" in c and c["id"] not in landed:
|
|
10477
|
+
STORE.set_shares("profile", c["id"], [])
|
|
9918
10478
|
else:
|
|
9919
10479
|
return None, (409, "the lab's connections kept changing while they were imported; import again")
|
|
9920
10480
|
said += lines
|