@fangorn-network/westmarch 0.0.4-dev → 0.0.6-dev
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/README.md +3 -0
- package/consume/explore.js +243 -0
- package/consume/mcp.js +30 -2
- package/consume/taste.js +51 -1
- package/consume/view-tools.js +1 -1
- package/dist/ts/eval.js +61 -7
- package/docs/app-to-agent.md +15 -17
- package/package.json +7 -4
- package/plugin/scripts/fx.mjs +268 -0
- package/publish/eval.ts +67 -12
- package/publish/ship.js +1 -1
- package/publish/view.js +19 -9
- package/site/agent.js +23 -0
- package/site/dist/assets/index-DxAeTEch.js +8 -0
- package/site/dist/index.html +5 -4
- package/site/dist/theme.css +7 -0
- package/site/store.js +10 -3
- package/site/dist/assets/index-CaHGhiKK.js +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6-dev",
|
|
4
4
|
"description": "Publisher and consumer surfaces for a Fangorn app: shape a graph, publish it, read it back as a searchable, agent-drivable catalog",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"./embed": "./consume/embed.js",
|
|
13
13
|
"./rank": "./consume/rank.js",
|
|
14
14
|
"./apps": "./consume/apps.js",
|
|
15
|
+
"./explore": "./consume/explore.js",
|
|
15
16
|
"./agent-card": "./publish/agent-card.js",
|
|
16
17
|
"./view": "./publish/view.js",
|
|
17
18
|
"./roles": "./consume/roles.js",
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
"scripts": {
|
|
37
38
|
"build": "tsc -p .",
|
|
38
39
|
"prepare": "tsc -p .",
|
|
39
|
-
"test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/view.js && node publish/ship.js --selfcheck && node publish/eval.ts --selfcheck && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js && node consume/x402.js && node site/feed.js && node site/store.js",
|
|
40
|
+
"test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/view.js && node publish/ship.js --selfcheck && node publish/eval.ts --selfcheck && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/explore.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js && node consume/x402.js && node site/feed.js && node site/store.js",
|
|
40
41
|
"lint": "node consume/lint.js"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
@@ -62,7 +63,8 @@
|
|
|
62
63
|
"fangorn-mcp": "./consume/mcp.js",
|
|
63
64
|
"westmarch-view": "./publish/view-cli.js",
|
|
64
65
|
"westmarch-ship": "./publish/ship.js",
|
|
65
|
-
"westmarch-eval": "./dist/ts/eval.js"
|
|
66
|
+
"westmarch-eval": "./dist/ts/eval.js",
|
|
67
|
+
"fangorn-fx": "./plugin/scripts/fx.mjs"
|
|
66
68
|
},
|
|
67
69
|
"files": [
|
|
68
70
|
"consume",
|
|
@@ -73,7 +75,8 @@
|
|
|
73
75
|
"site/feed.js",
|
|
74
76
|
"site/worker.js",
|
|
75
77
|
"site/store.js",
|
|
76
|
-
"dist/ts"
|
|
78
|
+
"dist/ts",
|
|
79
|
+
"plugin/scripts/fx.mjs"
|
|
77
80
|
],
|
|
78
81
|
"engines": {
|
|
79
82
|
"node": ">=20"
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Fangorn app index, headless. One command per verb, JSON on stdout.
|
|
3
|
+
//
|
|
4
|
+
// WHY THERE IS NO MCP SERVER HERE
|
|
5
|
+
// -------------------------------
|
|
6
|
+
// An index that POINTS AT apps rather than hosting them needs no runtime of its
|
|
7
|
+
// own. Every verb below is a function `@fangorn/westmarch` already exports, and
|
|
8
|
+
// the agent's shell is the transport. Nobody has to stand up a server to be in
|
|
9
|
+
// the index — a publisher bakes a view, and that is the whole obligation.
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS IS A PLUGIN AND NOT A PAGE
|
|
12
|
+
// -----------------------------------
|
|
13
|
+
// One thing here is not stateless: the taste kernel. It lives in
|
|
14
|
+
// ~/.fangorn/taste.json — on the filesystem, between the human and the agent —
|
|
15
|
+
// because it has to outlive any one app, any one tab and any one ORIGIN.
|
|
16
|
+
// localStorage cannot do that: a taste learned in fangorn.tv is invisible to the
|
|
17
|
+
// next app by construction, which is the whole reason apps keep rebuilding it.
|
|
18
|
+
// A file the agent can read is the shortest thing that carries it across.
|
|
19
|
+
//
|
|
20
|
+
// ponytail: a plaintext file, no wallet, no sync. `publish/vault.js` is the
|
|
21
|
+
// upgrade path — same object, sealed to a signature, kept in the graph — and it
|
|
22
|
+
// is worth doing the day the kernel needs to travel between machines, not now.
|
|
23
|
+
// ponytail: every command re-fetches its view. Fine at 917 rows, ~seconds at
|
|
24
|
+
// 42k; add a disk cache keyed by view URL when a big corpus makes it hurt.
|
|
25
|
+
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { configure, domainManifests, loadShard, trimView } from "../../consume/shard.js";
|
|
30
|
+
import { findCorpora, sourcesFromChain } from "../../consume/directory.js";
|
|
31
|
+
import { linkOf, rolesFrom, textOf, titleOf } from "../../consume/roles.js";
|
|
32
|
+
import { exportTaste, recommend, taste } from "../../consume/taste.js";
|
|
33
|
+
import { EMBED_MODEL, embedQuery, packVec, unpackVec } from "../../consume/embed.js";
|
|
34
|
+
import { browse, describe, facet, getRow, neighbors, search } from "../../consume/tools.js";
|
|
35
|
+
|
|
36
|
+
const HOME = process.env.FANGORN_HOME ?? join(homedir(), ".fangorn");
|
|
37
|
+
const TASTE = join(HOME, "taste.json");
|
|
38
|
+
|
|
39
|
+
// ── the kernel on disk ───────────────────────────────────────────────────────
|
|
40
|
+
const store = () => { try { return JSON.parse(readFileSync(TASTE, "utf8")); } catch { return { model: EMBED_MODEL, likes: [], dislikes: [] }; } };
|
|
41
|
+
const save = (s) => { mkdirSync(HOME, { recursive: true }); writeFileSync(TASTE, JSON.stringify(s, null, 1)); };
|
|
42
|
+
/** Picks are stored PACKED (base64 int8, the shard encoding) and unpacked here.
|
|
43
|
+
* Storing the picks rather than only the derived vector is what keeps the
|
|
44
|
+
* kernel correctable: you can read what it was built from and drop one. */
|
|
45
|
+
const picks = (l = []) => l.map((p) => ({ ...p, vector: unpackVec(p.v) })).filter((p) => p.vector);
|
|
46
|
+
const kernel = (s = store()) => taste(picks(s.likes), picks(s.dislikes));
|
|
47
|
+
|
|
48
|
+
// ── args ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
const argv = process.argv.slice(2);
|
|
50
|
+
const [cmd, ...rest] = argv;
|
|
51
|
+
const pos = rest.filter((a) => !a.startsWith("--"));
|
|
52
|
+
const flag = (n, d) => { const a = rest.find((x) => x.startsWith(`--${n}=`)); return a === undefined ? d : a.slice(n.length + 3); };
|
|
53
|
+
const num = (n, d) => { const v = flag(n); return v === undefined ? d : Number(v); };
|
|
54
|
+
const pairs = (s) => s ? Object.fromEntries(String(s).split(",").map((p) => p.split("=").map((x) => x.trim()))) : undefined;
|
|
55
|
+
const list = (s) => String(s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
56
|
+
const out = (o) => console.log(JSON.stringify(o, null, 1));
|
|
57
|
+
const die = (m) => { console.error(m); process.exit(1); };
|
|
58
|
+
|
|
59
|
+
// ── a view, loaded ───────────────────────────────────────────────────────────
|
|
60
|
+
let roles = rolesFrom([], []);
|
|
61
|
+
async function load(view) {
|
|
62
|
+
if (!view) die("need a view URL");
|
|
63
|
+
const base = trimView(view);
|
|
64
|
+
configure({
|
|
65
|
+
resolveView: () => base,
|
|
66
|
+
// Roles must be ready BEFORE the first row is parsed: rowText runs during
|
|
67
|
+
// parsing, so deriving them afterwards leaves every row with empty text
|
|
68
|
+
// and lexical search silently matching nothing.
|
|
69
|
+
onManifests: (ms) => { roles = rolesFrom(ms); },
|
|
70
|
+
rowText: (f) => textOf(f, roles),
|
|
71
|
+
});
|
|
72
|
+
const rows = await loadShard(base);
|
|
73
|
+
roles = rolesFrom(domainManifests(), rows);
|
|
74
|
+
let model = null;
|
|
75
|
+
try { model = (await (await fetch(`${base}/cdn/catalog`)).json()).embedding_model ?? null; } catch { /* unranked, not fatal */ }
|
|
76
|
+
return { rows, roles, base, model };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A query vector, or null with the reason. Never throws: every verb here
|
|
80
|
+
* degrades to lexical and SAYS so rather than returning confident nonsense. */
|
|
81
|
+
const qvOf = async (q) => { try { return { qv: await embedQuery(q), why: null }; } catch (e) { return { qv: null, why: e?.message ?? String(e) }; } };
|
|
82
|
+
|
|
83
|
+
/** The corpus and the kernel must share an embedding space or cosine between
|
|
84
|
+
* them is noise that still looks like a score. */
|
|
85
|
+
const mismatch = (model, s) => model && s.model && model !== s.model
|
|
86
|
+
? `taste was built with ${s.model}, this corpus is ${model} — scores would be noise` : null;
|
|
87
|
+
|
|
88
|
+
const sources = async () => {
|
|
89
|
+
const f = flag("sources"); if (f) return list(f);
|
|
90
|
+
if (process.env.FANGORN_SOURCES) return list(process.env.FANGORN_SOURCES);
|
|
91
|
+
try { return JSON.parse(readFileSync(join(HOME, "sources.json"), "utf8")); } catch { /* none pinned */ }
|
|
92
|
+
return chainSources();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Every app bound on chain, the scan fangorn-mcp's list-apps runs. From the
|
|
96
|
+
// current AppRegistry's first event on Arbitrum Sepolia; --from-block overrides.
|
|
97
|
+
async function chainSources() {
|
|
98
|
+
// The SDK scans 1,000 blocks per log request by default, which on the public
|
|
99
|
+
// RPC takes minutes; fangorn-mcp's install line sets the same window.
|
|
100
|
+
process.env.FANGORN_LOG_WINDOW ??= "100000";
|
|
101
|
+
const [{ Fangorn, FangornConfig }, { generatePrivateKey }] = await Promise.all([
|
|
102
|
+
import("@fangorn-network/sdk"), import("viem/accounts")]);
|
|
103
|
+
const fangorn = Fangorn.create({ privateKey: generatePrivateKey(), config: FangornConfig }); // reads only
|
|
104
|
+
const { sources } = await sourcesFromChain(fangorn, { fromBlock: BigInt(flag("from-block", "311637349")) });
|
|
105
|
+
return sources;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── verbs ────────────────────────────────────────────────────────────────────
|
|
109
|
+
switch (cmd) {
|
|
110
|
+
|
|
111
|
+
// WHICH app has this. Downloads no corpus — coverage centroids only, ~4KB each.
|
|
112
|
+
case "find": {
|
|
113
|
+
const src = await sources();
|
|
114
|
+
if (!src.length) die("no apps found on chain; pass --sources=a,b or write ~/.fangorn/sources.json");
|
|
115
|
+
out(await findCorpora(pos.join(" "), { sources: src, embed: embedQuery, model: EMBED_MODEL, limit: num("limit", 10) }));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case "describe": {
|
|
120
|
+
const { rows, roles: r, model } = await load(pos[0]);
|
|
121
|
+
out({
|
|
122
|
+
...describe(rows), model,
|
|
123
|
+
shape: r.declared ? "declared" : "sniffed",
|
|
124
|
+
title: r.title, subtitle: r.subtitle, tags: r.tags, text: r.text,
|
|
125
|
+
entityTypes: r.entityTypes, gates: r.gates,
|
|
126
|
+
// Where the reader goes next. The index's job ends here.
|
|
127
|
+
launch: r.launch, externalUrl: r.externalUrl, actions: r.actions,
|
|
128
|
+
});
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "browse": {
|
|
133
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
134
|
+
out(browse(rows, r, { type: flag("type"), where: pairs(flag("where")), sort: flag("sort"),
|
|
135
|
+
desc: flag("asc") === undefined, limit: num("limit", 20), offset: num("offset", 0), fields: list(flag("fields")) }));
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "search": {
|
|
140
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
141
|
+
const q = pos.slice(1).join(" ");
|
|
142
|
+
const { qv, why } = await qvOf(q);
|
|
143
|
+
out({ query: q, mode: qv ? "semantic where rows have vectors" : "lexical", ...(why ? { why } : {}),
|
|
144
|
+
hits: search(rows, q, r, { qv, limit: num("limit", 10), where: pairs(flag("where")), fields: list(flag("fields")) }) });
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "facet": {
|
|
149
|
+
const { rows } = await load(pos[0]);
|
|
150
|
+
out(facet(rows, pos[1], { limit: num("limit", 20), where: pairs(flag("where")) }));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "row": {
|
|
155
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
156
|
+
const g = getRow(rows, pos[1], r);
|
|
157
|
+
out(g ? { ...g, url: linkOf(rows.find((x) => x.id === g.id) ?? {}, r) } : null);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case "similar": {
|
|
162
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
163
|
+
out(neighbors(rows, pos[1], r, { limit: num("limit", 10), fields: list(flag("fields")) }));
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the kernel ───────────────────────────────────────────────────────────────
|
|
168
|
+
case "like": case "dislike": {
|
|
169
|
+
const { rows, roles: r, base, model } = await load(pos[0]);
|
|
170
|
+
const s = store();
|
|
171
|
+
const into = cmd === "like" ? s.likes : s.dislikes;
|
|
172
|
+
const added = [];
|
|
173
|
+
for (const id of pos.slice(1)) {
|
|
174
|
+
const row = rows.find((x) => x.id === id);
|
|
175
|
+
if (!row) { console.error(`no row ${id}`); continue; }
|
|
176
|
+
if (!row.vector) { console.error(`${id} has no vector — nothing to learn from`); continue; }
|
|
177
|
+
// Newest last: taste() weights by recency, so order in the file is signal.
|
|
178
|
+
const at = into.findIndex((p) => p.id === id);
|
|
179
|
+
if (at >= 0) into.splice(at, 1);
|
|
180
|
+
into.push({ id, title: titleOf(row, r), view: base, at: new Date().toISOString().slice(0, 10), v: packVec(Array.from(row.vector)) });
|
|
181
|
+
added.push(id);
|
|
182
|
+
}
|
|
183
|
+
s.model ??= model ?? EMBED_MODEL;
|
|
184
|
+
save(s);
|
|
185
|
+
const t = kernel(s);
|
|
186
|
+
out({ added, likes: s.likes.length, dislikes: s.dislikes.length, taste: t && exportTaste(t), file: TASTE });
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case "forget": {
|
|
191
|
+
const s = store();
|
|
192
|
+
const drop = new Set(pos);
|
|
193
|
+
const before = s.likes.length + s.dislikes.length;
|
|
194
|
+
s.likes = s.likes.filter((p) => !drop.has(p.id));
|
|
195
|
+
s.dislikes = s.dislikes.filter((p) => !drop.has(p.id));
|
|
196
|
+
save(s);
|
|
197
|
+
out({ removed: before - s.likes.length - s.dislikes.length, likes: s.likes.length, dislikes: s.dislikes.length });
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "taste": {
|
|
202
|
+
const s = store();
|
|
203
|
+
const t = kernel(s);
|
|
204
|
+
out(t ? { model: s.model, n: t.n, from: t.from, rejected: t.rejected, drift: t.drift,
|
|
205
|
+
// Where each pick was learned. A kernel built in one publisher's
|
|
206
|
+
// corpus is meant to be pointed at another's — this is the record
|
|
207
|
+
// of whether it actually has been.
|
|
208
|
+
views: [...new Set([...s.likes, ...s.dislikes].map((p) => p.view))],
|
|
209
|
+
export: exportTaste(t), file: TASTE }
|
|
210
|
+
: { n: 0, file: TASTE, note: "nothing liked yet — `fx like <view> <id>`" });
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The headline: rank a corpus by a taste learned somewhere else entirely.
|
|
215
|
+
case "recommend": {
|
|
216
|
+
const { rows, roles: r, model } = await load(pos[0]);
|
|
217
|
+
const s = store();
|
|
218
|
+
const t = kernel(s);
|
|
219
|
+
if (!t) die("no taste yet — `fx like <view> <id>` first");
|
|
220
|
+
const warn = mismatch(model, s);
|
|
221
|
+
if (warn) die(warn);
|
|
222
|
+
const seen = new Set([...s.likes, ...s.dislikes].map((p) => p.id));
|
|
223
|
+
out({ from: t.from, against: pos[0],
|
|
224
|
+
hits: recommend(rows, t, { limit: num("limit", 10), exclude: seen })
|
|
225
|
+
.map(({ row, score }) => ({ id: row.id, title: titleOf(row, r), score, url: linkOf(row, r) })) });
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── the handoff. The index points; the app does the work. ────────────────────
|
|
230
|
+
case "launch": {
|
|
231
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
232
|
+
const id = flag("row");
|
|
233
|
+
if (id) {
|
|
234
|
+
const row = rows.find((x) => x.id === id);
|
|
235
|
+
if (!row) die(`no row ${id}`);
|
|
236
|
+
out({ url: linkOf(row, r), title: titleOf(row, r) });
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
if (!r.launch) die("this corpus declares no presentation.launch — nowhere to hand off to");
|
|
240
|
+
const t = kernel();
|
|
241
|
+
const fill = { taste: t ? JSON.stringify(exportTaste(t)) : "", q: pos.slice(1).join(" ") };
|
|
242
|
+
const url = String(r.launch).replace(/\{(\w+)\}/g, (_, k) => encodeURIComponent(fill[k] ?? ""));
|
|
243
|
+
const u = new URL(url);
|
|
244
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") die(`refusing a ${u.protocol} launch URL`);
|
|
245
|
+
// The kernel travels in the URL because that is the only channel a page on
|
|
246
|
+
// someone else's origin can receive it on. It is the app's copy from here.
|
|
247
|
+
out({ url: u.toString(), carries: t ? { n: t.n, from: t.from } : null });
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
default:
|
|
252
|
+
console.log(`fx <verb>
|
|
253
|
+
|
|
254
|
+
find <query> [--sources=a,b|--from-block=N] which app has this. No corpus downloaded.
|
|
255
|
+
describe <view> rows, field coverage, roles, where it hands off
|
|
256
|
+
browse <view> [--type= --where=k=v --sort= --asc --limit= --offset=]
|
|
257
|
+
search <view> <query> [--where=k=v --limit=] semantic where rows have vectors, else lexical
|
|
258
|
+
facet <view> <field> [--where=k=v --limit=] count a field's values
|
|
259
|
+
row <view> <id> one record, whole
|
|
260
|
+
similar <view> <id> [--limit=] nearest by vector
|
|
261
|
+
|
|
262
|
+
like|dislike <view> <id...> teach the kernel. Writes ${TASTE}
|
|
263
|
+
forget <id...> drop picks from it
|
|
264
|
+
taste what it is, and what it was built from
|
|
265
|
+
recommend <view> [--limit=] rank ANY corpus by it
|
|
266
|
+
|
|
267
|
+
launch <view> [query] | launch <view> --row=<id> the handoff URL, carrying the kernel`);
|
|
268
|
+
}
|
package/publish/eval.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]
|
|
5
5
|
//
|
|
6
|
-
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
7
6
|
// westmarch-eval --selfcheck
|
|
8
7
|
//
|
|
8
|
+
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
9
|
+
//
|
|
9
10
|
// It grades the RECIPE — how records are shaped, which fields are embedded, how rows
|
|
10
11
|
// rank — never the data. New records are facts, and a view is not worse for holding
|
|
11
12
|
// them. So: build a base view (main's recipe) and a candidate (the change) from the
|
|
@@ -25,6 +26,16 @@
|
|
|
25
26
|
// {"id":"addresses","kind":"records","expect":{"match":{"text":"/\\b\\d{2,6} [A-Z]\\w+ (St|Ave)\\b/"}},"max_pct":0.5,"hard":true}
|
|
26
27
|
// records: the % of rows that satisfy `expect`, lower is better; `hard`: any rise over the base fails.
|
|
27
28
|
// `of` (a predicate) narrows which rows count, e.g. only meeting items have a date to miss.
|
|
29
|
+
// {"id":"basin-towns","kind":"coverage","field":"city","values":["Plover","Stevens Point"],"min":0.9}
|
|
30
|
+
// coverage: the share of `values` that some row holds in `field`, and the missing ones.
|
|
31
|
+
// `values` may instead be a path (relative to the golden file) to a JSON list, or to an
|
|
32
|
+
// object {value: weight} such as each town's population: the share is then by weight, and
|
|
33
|
+
// the missing are listed heaviest first, so the report names the next places to add.
|
|
34
|
+
// With a base, any fall fails: a recipe that loses a town has dropped its records.
|
|
35
|
+
//
|
|
36
|
+
// Any check may carry a `goal`: the owner's bet it serves (eval/goals.md says what each one
|
|
37
|
+
// is). The report scores each goal apart, so "trades 0.4, general 0.9" shows which bet the
|
|
38
|
+
// app is losing, instead of one mean that hides it.
|
|
28
39
|
//
|
|
29
40
|
// A predicate is {where?, match?, not?}. `where` is fangorn's own whole-value,
|
|
30
41
|
// case-insensitive match — the filter agents use. `match` maps a field (or several
|
|
@@ -37,17 +48,20 @@
|
|
|
37
48
|
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
38
49
|
import { createServer } from "node:http";
|
|
39
50
|
import { homedir } from "node:os";
|
|
51
|
+
import { dirname, join } from "node:path";
|
|
40
52
|
import { configure, loadShard, trimView } from "@fangorn-network/westmarch/shard";
|
|
41
|
-
import { rolesFrom, textOf, titleOf, subtitleOf } from "@fangorn-network/westmarch/roles";
|
|
53
|
+
import { rolesFrom, textOf, titleOf, subtitleOf, values } from "@fangorn-network/westmarch/roles";
|
|
42
54
|
import { search, matches } from "@fangorn-network/westmarch/tools";
|
|
43
55
|
import { EMBED_MODEL, embedQueryDirect } from "@fangorn-network/westmarch/embed";
|
|
44
56
|
|
|
45
57
|
type Pred = { where?: Record<string, string>; match?: Record<string, string>; not?: Pred };
|
|
46
|
-
type Check =
|
|
47
|
-
| {
|
|
48
|
-
| {
|
|
49
|
-
| {
|
|
50
|
-
|
|
58
|
+
type Check = { id: string; goal?: string } & (
|
|
59
|
+
| { kind?: "search"; q: string; k?: number; expect: Pred; min?: number }
|
|
60
|
+
| { kind: "count"; where?: Record<string, string>; expect?: Pred; min?: number; max?: number }
|
|
61
|
+
| { kind: "records"; of?: Pred; expect: Pred; max_pct: number; hard?: boolean }
|
|
62
|
+
| { kind: "coverage"; field: string; values: string[]; weights?: Record<string, number>; of?: Pred; min?: number });
|
|
63
|
+
type Result = { id: string; kind: "search" | "count" | "records" | "coverage" | "known"; score: number; pass: boolean;
|
|
64
|
+
goal?: string; hard?: boolean; top?: string[] };
|
|
51
65
|
type QueryVector = (q: string) => Promise<Float32Array | null>;
|
|
52
66
|
|
|
53
67
|
/** A drop below this, in mean precision@k or known@10, is "worse". canton-corpus's number. */
|
|
@@ -74,19 +88,28 @@ async function grade(rows: Row[], roles: Roles, checks: Check[], qv: QueryVector
|
|
|
74
88
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
75
89
|
const out: Result[] = [];
|
|
76
90
|
for (const c of checks) {
|
|
91
|
+
const goal = c.goal ? { goal: c.goal } : {};
|
|
77
92
|
if (c.kind === "count") {
|
|
78
93
|
const ok = compile(c.expect);
|
|
79
94
|
const n = rows.filter((r) => matches(r, c.where) && ok(r)).length;
|
|
80
|
-
out.push({ id: c.id, kind: "count", score: n, pass: n >= (c.min ?? 1) && n <= (c.max ?? Infinity) });
|
|
95
|
+
out.push({ id: c.id, kind: "count", score: n, pass: n >= (c.min ?? 1) && n <= (c.max ?? Infinity), ...goal });
|
|
81
96
|
} else if (c.kind === "records") {
|
|
82
97
|
const of = rows.filter(compile(c.of)), ok = compile(c.expect);
|
|
83
98
|
const pct = of.length ? round((100 * of.filter(ok).length) / of.length) : 0;
|
|
84
|
-
out.push({ id: c.id, kind: "records", score: pct, pass: pct <= c.max_pct, hard: c.hard });
|
|
99
|
+
out.push({ id: c.id, kind: "records", score: pct, pass: pct <= c.max_pct, hard: c.hard, ...goal });
|
|
100
|
+
} else if (c.kind === "coverage") {
|
|
101
|
+
// The same whole-value, any-case reading `where` uses, so covered means an agent's filter finds it.
|
|
102
|
+
const held = new Set(rows.filter(compile(c.of)).flatMap((r) => values(r[c.field] ?? "").map((v) => v.toLowerCase())));
|
|
103
|
+
const w = (v: string) => c.weights?.[v] ?? 1;
|
|
104
|
+
const missing = c.values.filter((v) => !held.has(v.toLowerCase())).sort((a, b) => w(b) - w(a));
|
|
105
|
+
const total = c.values.reduce((a, v) => a + w(v), 0);
|
|
106
|
+
const score = total ? round(1 - missing.reduce((a, v) => a + w(v), 0) / total) : 1;
|
|
107
|
+
out.push({ id: c.id, kind: "coverage", score, pass: score >= (c.min ?? 0), top: missing.slice(0, 10), ...goal });
|
|
85
108
|
} else {
|
|
86
109
|
const k = c.k ?? 5, ok = compile(c.expect);
|
|
87
110
|
const hits = search(rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id)!);
|
|
88
111
|
const score = round(hits.filter(ok).length / k);
|
|
89
|
-
out.push({ id: c.id, kind: "search", score, pass: score >= (c.min ?? 0), top: hits.slice(0, 3).map((r) => titleOf(r, roles).slice(0, 90)) });
|
|
112
|
+
out.push({ id: c.id, kind: "search", score, pass: score >= (c.min ?? 0), top: hits.slice(0, 3).map((r) => titleOf(r, roles).slice(0, 90)), ...goal });
|
|
90
113
|
}
|
|
91
114
|
}
|
|
92
115
|
// ponytail: the pool is each view's own rows, so base and candidate sample different
|
|
@@ -110,6 +133,19 @@ async function grade(rows: Row[], roles: Roles, checks: Check[], qv: QueryVector
|
|
|
110
133
|
return out;
|
|
111
134
|
}
|
|
112
135
|
|
|
136
|
+
/** Each goal's checks, scored apart: how many pass, and the mean of its searches and coverages. */
|
|
137
|
+
function byGoal(results: Result[]) {
|
|
138
|
+
const out: Record<string, { checks: number; passing: number; search: number | null; coverage: number | null }> = {};
|
|
139
|
+
const mean = (xs: number[]) => (xs.length ? round(xs.reduce((a, x) => a + x, 0) / xs.length) : null);
|
|
140
|
+
for (const g of new Set(results.map((r) => r.goal).filter((g): g is string => !!g))) {
|
|
141
|
+
const rs = results.filter((r) => r.goal === g);
|
|
142
|
+
out[g] = { checks: rs.length, passing: rs.filter((r) => r.pass).length,
|
|
143
|
+
search: mean(rs.filter((r) => r.kind === "search").map((r) => r.score)),
|
|
144
|
+
coverage: mean(rs.filter((r) => r.kind === "coverage").map((r) => r.score)) };
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
113
149
|
/** The candidate against the base. Worse on any one rule is worse. */
|
|
114
150
|
function compare(cand: Result[], base: Result[]) {
|
|
115
151
|
const was = new Map(base.map((r) => [r.id, r.score]));
|
|
@@ -120,6 +156,7 @@ function compare(cand: Result[], base: Result[]) {
|
|
|
120
156
|
// The ratchet: a question the base answered at all, the candidate must too.
|
|
121
157
|
if (r.kind === "search" && b !== undefined && b > 0 && r.score === 0) failures.push(`${r.id}: answered before, nothing now`);
|
|
122
158
|
if (r.hard && b !== undefined && r.score > b) failures.push(`${r.id}: ${b}% → ${r.score}%, and it may not rise`);
|
|
159
|
+
if (r.kind === "coverage" && b !== undefined && r.score < b) failures.push(`${r.id}: coverage ${b} → ${r.score}; missing ${r.top?.join(", ")}`);
|
|
123
160
|
}
|
|
124
161
|
// Aggregates, over the checks both sides ran: one question moving a rank is noise.
|
|
125
162
|
const both = (kind: Result["kind"]) => cand.filter((r) => (kind === "known" ? r.id === "known@10" : r.kind === kind) && was.has(r.id));
|
|
@@ -150,8 +187,14 @@ async function main() {
|
|
|
150
187
|
console.error("usage: westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]");
|
|
151
188
|
process.exit(flags.help ? 0 : 2);
|
|
152
189
|
}
|
|
153
|
-
const
|
|
190
|
+
const golden = String(flags.golden ?? "eval/golden.jsonl");
|
|
191
|
+
const checks: Check[] = readFileSync(golden, "utf8")
|
|
154
192
|
.split("\n").filter((l) => l.trim() && !l.startsWith("//")).map((l) => JSON.parse(l));
|
|
193
|
+
// A coverage list kept in its own file: a list, or {value: weight}.
|
|
194
|
+
for (const c of checks) if (c.kind === "coverage" && typeof c.values === "string") {
|
|
195
|
+
const list: string[] | Record<string, number> = JSON.parse(readFileSync(join(dirname(golden), c.values), "utf8"));
|
|
196
|
+
Object.assign(c, Array.isArray(list) ? { values: list } : { values: Object.keys(list), weights: list });
|
|
197
|
+
}
|
|
155
198
|
const known = Number(flags.known ?? 200);
|
|
156
199
|
|
|
157
200
|
const { env } = await import("@huggingface/transformers");
|
|
@@ -172,7 +215,7 @@ async function main() {
|
|
|
172
215
|
const failures = cmp?.failures ?? cand.results.filter((r) => !r.pass).map((r) => `${r.id}: ${r.score} is outside its bounds`);
|
|
173
216
|
// What reproduces this grade: the model, the runtime, and the views (whose manifests hash every shard).
|
|
174
217
|
const report = { model: EMBED_MODEL, node: process.version, platform: `${process.platform}-${process.arch}`,
|
|
175
|
-
candidate: cand, base, verdict: cmp?.verdict ?? null, deltas: cmp?.deltas ?? [], failures, pass: !failures.length };
|
|
218
|
+
goals: byGoal(cand.results), candidate: cand, base, verdict: cmp?.verdict ?? null, deltas: cmp?.deltas ?? [], failures, pass: !failures.length };
|
|
176
219
|
|
|
177
220
|
if (flags.report) writeFileSync(String(flags.report), JSON.stringify(report, null, 1));
|
|
178
221
|
if (flags.json) console.log(JSON.stringify(report, null, 1));
|
|
@@ -184,6 +227,8 @@ async function main() {
|
|
|
184
227
|
console.log(`${r.pass ? " " : "✗"} ${r.kind.padEnd(7)} ${r.id.padEnd(32)} ${b === undefined ? "" : `${b} → `}${r.score}`);
|
|
185
228
|
}
|
|
186
229
|
for (const d of report.deltas) console.log(`\n${d.kind}: ${d.base} → ${d.cand}`);
|
|
230
|
+
for (const [g, s] of Object.entries(report.goals))
|
|
231
|
+
console.log(`\ngoal ${g}: ${s.passing}/${s.checks} passing${s.search === null ? "" : `, search ${s.search}`}${s.coverage === null ? "" : `, coverage ${s.coverage}`}`);
|
|
187
232
|
console.log(`\n${report.verdict ?? (report.pass ? "pass" : "fail")}${failures.length ? `\n ${failures.join("\n ")}` : ""}`);
|
|
188
233
|
}
|
|
189
234
|
process.exit(report.pass ? 0 : 1);
|
|
@@ -234,6 +279,15 @@ async function selfcheck() {
|
|
|
234
279
|
assert(scoped.score === 50, `\`of\` narrows the rows a record check counts: ${scoped.score}`);
|
|
235
280
|
assert(r["known@10"].score === 1, "every title finds its own row");
|
|
236
281
|
|
|
282
|
+
// Coverage, and goals scored apart.
|
|
283
|
+
const [cov] = await grade(rows, roles, [{ id: "towns", kind: "coverage", field: "city", values: ["plover", "Stevens Point", "Wausau"], min: 0.5, goal: "towns" }], async () => null, 0);
|
|
284
|
+
assert(cov.score === 0.667 && cov.pass && cov.top?.[0] === "Wausau", `coverage is the share present, the rest listed: ${JSON.stringify(cov)}`);
|
|
285
|
+
const [pop] = await grade(rows, roles, [{ id: "pop", kind: "coverage", field: "city", values: ["Plover", "Wausau", "Adell"],
|
|
286
|
+
weights: { Plover: 30, Wausau: 60, Adell: 10 } }], async () => null, 0);
|
|
287
|
+
assert(pop.score === 0.3 && pop.top?.join() === "Wausau,Adell", `weighted coverage, the heaviest gap first: ${JSON.stringify(pop)}`);
|
|
288
|
+
const goals = byGoal([cov, { ...r.rezoning, goal: "towns" }, r.plover]);
|
|
289
|
+
assert(goals.towns.checks === 2 && goals.towns.coverage === 0.667 && goals.towns.search === 1 && !("undefined" in goals), `goals: ${JSON.stringify(goals)}`);
|
|
290
|
+
|
|
237
291
|
// The verdict.
|
|
238
292
|
const res = (id: string, score: number, kind: Result["kind"] = "search", extra = {}): Result => ({ id, kind, score, pass: true, ...extra });
|
|
239
293
|
const base = [res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })];
|
|
@@ -246,5 +300,6 @@ async function selfcheck() {
|
|
|
246
300
|
assert(compare([res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.2, "records", { hard: true })], base).verdict === "worse", "a hard record may not rise");
|
|
247
301
|
assert(compare([res("q1", 0.8, "search", { pass: false }), res("q2", 0.4)], base).verdict === "worse", "a floor fails whatever the base did");
|
|
248
302
|
assert(compare([res("q1", 0.8), res("q2", 0.4), res("q3", 0)], base).verdict === "unchanged", "a new question has no base to fall from");
|
|
303
|
+
assert(compare([res("cov", 0.9, "coverage")], [res("cov", 1, "coverage")]).verdict === "worse", "coverage may not fall");
|
|
249
304
|
console.log("eval: selfcheck ok");
|
|
250
305
|
}
|
package/publish/ship.js
CHANGED
|
@@ -235,7 +235,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
235
235
|
if (!existsSync(join(site, "view/cdn/catalog")) && state.url && !dry) await mirrorView(`${state.url}/view`, join(site, "view"));
|
|
236
236
|
const namespaces = [...new Set(cfg.sources.map((s) => s.namespace))];
|
|
237
237
|
const report = dry ? {} : await view.publishView({ fangorn, app: cfg.app, namespaces, out: join(site, "view"),
|
|
238
|
-
fromBlock: BigInt(state.fromBlock), log });
|
|
238
|
+
fromBlock: BigInt(state.fromBlock), embed: view.cachedEmbed(join(dir, "vectors.ndjson")), log });
|
|
239
239
|
|
|
240
240
|
// 6. the page, the card, the headers
|
|
241
241
|
if (!existsSync(join(STOCK, "index.html"))) throw new Error(`no stock page at ${STOCK}; run \`npx vite build\` in westmarch/site`);
|
package/publish/view.js
CHANGED
|
@@ -201,7 +201,7 @@ export function liveFields(out) {
|
|
|
201
201
|
* Returns per-domain counts of what changed.
|
|
202
202
|
*/
|
|
203
203
|
export async function publishView({ fangorn, app, namespaces, out, fromBlock = 0n, rebake = false,
|
|
204
|
-
embed = embedDocumentDirect, log = console.log }) {
|
|
204
|
+
embed = embedDocumentDirect, log = console.log, shardRows = 20000 }) {
|
|
205
205
|
if (!namespaces?.length) throw new Error("publishView: name the data namespace(s) to publish");
|
|
206
206
|
fangorn.setAppId(app);
|
|
207
207
|
const appId = fangorn.getAppId();
|
|
@@ -222,7 +222,7 @@ export async function publishView({ fangorn, app, namespaces, out, fromBlock = 0
|
|
|
222
222
|
for (const { owner: publisher } of timelines) {
|
|
223
223
|
const domain = domainFor(appId, publisher, ns);
|
|
224
224
|
report[domain] = await publishDomain({
|
|
225
|
-
fangorn, schema, publisher, ns, domain, dir: `${out}/cdn/domains/${domain}`, rebake, embed, log,
|
|
225
|
+
fangorn, schema, publisher, ns, domain, dir: `${out}/cdn/domains/${domain}`, rebake, embed, log, shardRows,
|
|
226
226
|
});
|
|
227
227
|
}
|
|
228
228
|
}
|
|
@@ -230,7 +230,7 @@ export async function publishView({ fangorn, app, namespaces, out, fromBlock = 0
|
|
|
230
230
|
return report;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, rebake, embed, log }) {
|
|
233
|
+
async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, rebake, embed, log, shardRows }) {
|
|
234
234
|
const { contents } = await fangorn.readNamespace(publisher, ns);
|
|
235
235
|
const records = new Map((contents.vertices ?? []).filter((v) => v.payload && typeof v.payload === "object")
|
|
236
236
|
.map((v) => [v.cid, { ...v.payload, entityType: v.schemaId }]));
|
|
@@ -275,12 +275,17 @@ async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, reba
|
|
|
275
275
|
mkdirSync(`${dir}/shards`, { recursive: true });
|
|
276
276
|
const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
|
|
277
277
|
distance: "Cosine", embedder: EMBEDDER, filter: { owner: [publisher], namespace: [ns] }, shards: [], tombstones: [] };
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
278
|
+
// Pages serves files up to 25 MiB; 52k Steam games gzipped to 28 MiB in one shard.
|
|
279
|
+
// ponytail: split by row count (~550 B a row gzipped); split by bytes if rows get much fatter.
|
|
280
|
+
const writeShard = (all) => {
|
|
281
|
+
for (let i = 0; i < all.length; i += shardRows) {
|
|
282
|
+
const ls = all.slice(i, i + shardRows);
|
|
283
|
+
const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
|
|
284
|
+
const sha256 = createHash("sha256").update(gz).digest("hex");
|
|
285
|
+
const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${sha256.slice(0, 12)}.ndjson.gz`;
|
|
286
|
+
writeFileSync(`${dir}/shards/${file}`, gz);
|
|
287
|
+
manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256 });
|
|
288
|
+
}
|
|
284
289
|
};
|
|
285
290
|
if (lines.length) {
|
|
286
291
|
writeShard(lines);
|
|
@@ -389,6 +394,11 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
389
394
|
const run = () => publishView({ fangorn, app: APP, namespaces: ["shop"], out, embed, log: () => {} });
|
|
390
395
|
|
|
391
396
|
const d = domainFor(APP, PUB, "shop");
|
|
397
|
+
const split = mkdtempSync(`${tmpdir()}/view-`);
|
|
398
|
+
await publishView({ fangorn, app: APP, namespaces: ["shop"], out: split, embed, log: () => {}, shardRows: 2 });
|
|
399
|
+
m0: { const m = readJson(`${split}/cdn/domains/${d}/manifest`);
|
|
400
|
+
eq([m.count, m.shards.map((s) => s.count)], [3, [2, 1]], "rows past shardRows go to the next shard (Pages caps a file at 25 MiB)"); }
|
|
401
|
+
embedded.length = 0;
|
|
392
402
|
eq((await run())[d], { added: 3, removed: 0 }, "first run embeds everything");
|
|
393
403
|
if (!embedded.includes("Title: Oak table. Tags: . a solid oak dining table")) throw new Error(`declared roles compose the text: ${embedded}`);
|
|
394
404
|
let m = readJson(`${out}/cdn/domains/${d}/manifest`);
|
package/site/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The stock page's tools for agents in a browser: the same verbs as fangorn-mcp's data
|
|
2
2
|
// tools, over the rows this page loaded. `ctx` holds them; ship's card captures these.
|
|
3
3
|
import { describe, facet, getRow, search } from "../consume/tools.js";
|
|
4
|
+
import { KNOBS, discover } from "../consume/taste.js";
|
|
4
5
|
|
|
5
6
|
const ok = (o) => ({ content: [{ type: "text", text: JSON.stringify(o) }] });
|
|
6
7
|
const obj = (properties, required = []) => ({ type: "object", properties, required });
|
|
@@ -41,4 +42,26 @@ export function registerAgent(ctx) {
|
|
|
41
42
|
inputSchema: obj({}),
|
|
42
43
|
execute: async () => ok(ctx.session?.() ?? { saved: [], searches: [], taste: null }),
|
|
43
44
|
});
|
|
45
|
+
// Like / dislike and "For you": the same calls the page's 👍 👎 and knobs make (ctx.rate,
|
|
46
|
+
// ctx.votes, set by main.js), so what an agent records shows on the page.
|
|
47
|
+
mc.registerTool({
|
|
48
|
+
name: "rate",
|
|
49
|
+
description: "Record that the person liked or disliked a record (or clear it), by the id search returns. Builds the taste discover ranks with.",
|
|
50
|
+
inputSchema: obj({ id: { type: "string" }, verdict: { type: "string", enum: ["like", "dislike", "clear"] } }, ["id", "verdict"]),
|
|
51
|
+
execute: async ({ id, verdict }) => ok(ctx.rate?.(id, verdict) ?? { error: "this page keeps no taste" }),
|
|
52
|
+
});
|
|
53
|
+
mc.registerTool({
|
|
54
|
+
name: "discover",
|
|
55
|
+
description: "Records for a taste: a kernel over what was liked (newest last) and disliked, not a text search. Pass likes and dislikes (ids from search), or omit them to use the taste on this page. Knobs are 0–1.",
|
|
56
|
+
inputSchema: obj({ likes: { type: "array", items: { type: "string" } }, dislikes: { type: "array", items: { type: "string" } },
|
|
57
|
+
...Object.fromEntries(Object.entries(KNOBS).map(([k, [d, what]]) => [k, { type: "number", minimum: 0, maximum: 1, default: d, description: what }])),
|
|
58
|
+
seed: { type: "integer", description: "Same seed, same shuffle" }, limit: { type: "integer", minimum: 1, maximum: 50, default: 10 } }),
|
|
59
|
+
execute: async ({ likes, dislikes, limit = 10, ...knobs }) => {
|
|
60
|
+
const byId = (ids) => ids.map((id) => ctx.rows.find((r) => r.id === getRow(ctx.rows, id, ctx.roles)?.id)).filter((r) => r?.vector);
|
|
61
|
+
const v = likes ? { likes: byId(likes), dislikes: byId(dislikes ?? []) } : ctx.votes?.() ?? { likes: [], dislikes: [] };
|
|
62
|
+
if (!v.likes.length) return ok({ error: "like at least one record first (ids from search, or rate)" });
|
|
63
|
+
const out = discover(ctx.rows, v.likes, v.dislikes, { ...knobs, limit: Math.min(50, limit) });
|
|
64
|
+
return ok({ ...out, picks: out.picks.map(({ row, score }) => ({ ...getRow(ctx.rows, row.id, ctx.roles), score })) });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
44
67
|
}
|