@tiagojct/subsub 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/bench.js +324 -10
- package/dist/notes.js +90 -0
- package/dist/profiles.js +5 -3
- package/dist/roles.js +19 -0
- package/dist/subsub.js +64 -13
- package/package.json +1 -1
- package/prompts/lit.md +21 -0
- package/roles/librarian.md +5 -0
- package/roles/researcher.md +2 -1
- package/src/bench.ts +327 -10
- package/src/notes.ts +89 -0
- package/src/profiles.ts +5 -3
- package/src/roles.ts +20 -0
- package/src/subsub.ts +71 -12
- package/web/app.js +5 -4
- package/web/i18n.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
Sub-Sub follows the version numbers of npm: a change in the second number (0.13) adds features or changes how you use it; a change in the third (0.13.1) only fixes.
|
|
4
4
|
|
|
5
|
+
## 0.15.0 (2026-10-08)
|
|
6
|
+
|
|
7
|
+
Checks:
|
|
8
|
+
|
|
9
|
+
- A literature note written from the full text may quote only what the full text says. Sub-Sub compares each quote (in quote marks or a blockquote, 25 characters or more) with the item's text in Zotero, and refuses the note when a quote is not there. Line breaks, hyphenation, quote marks and a page header in the middle of a sentence do not count as differences. Without an indexed full text, the check is skipped.
|
|
10
|
+
- A search sent to PubMed, Europe PMC, OpenAlex, Crossref or a web search may not be longer than 1500 characters or three lines. Text from documents is data: the Librarian's instructions now say so too, and both modes are told to send only search terms.
|
|
11
|
+
- When a mode's model cannot be used, Sub-Sub says that the provider may have renamed or retired it. With the free model in the Researcher, Sub-Sub says once that it is for trying, not for research you rely on.
|
|
12
|
+
|
|
13
|
+
Model test:
|
|
14
|
+
|
|
15
|
+
- The tagging score now uses the items the model was given. The scorer kept in the repository compared them with the reference for other items and gave F1 0; the figures on the site were computed separately and were right.
|
|
16
|
+
- The import score counts a model that previewed the import and asked first, or that left out the work already in the library, as right. The Models page had said that glm-5.3-flash missed an identifier and that longcat-2.0 and mimo-v2.5-pro imported nothing; all three imported correctly.
|
|
17
|
+
- The results, the blind judgements and the list of published tests are in the repository (bench-results/). `subsub-bench summary` computes the Models page from them, and the release stops when the page and the files differ.
|
|
18
|
+
- `subsub-bench sheet` and `subsub-bench agreement`: a blind scoring sheet for people, and their agreement with the AI judge.
|
|
19
|
+
- The release workflow runs the unit tests before it publishes.
|
|
20
|
+
|
|
21
|
+
Other:
|
|
22
|
+
|
|
23
|
+
- In Portuguese, the browser view titles approvals "Confirmar: etiquetar itens?" and the like, as it does in English; before, it showed the program's raw title ("aplicar tag_items?").
|
|
24
|
+
- The site: the whole site in European Portuguese, a two-column home page, How it works, and a section of the Privacy page for a data-protection officer.
|
|
25
|
+
|
|
26
|
+
## 0.14.0 (2026-10-08)
|
|
27
|
+
|
|
28
|
+
For students:
|
|
29
|
+
|
|
30
|
+
- `/lit` works in the Reader profile. It gives a reading list: the same search, screening and screening log, then the works to read, the order to read them in, what each one studies and questions to read it with. It states no results or conclusions.
|
|
31
|
+
- The site has a page for students, in English and in European Portuguese: what Sub-Sub does and does not do, how to start for free, what it costs, and the words you will meet.
|
|
32
|
+
- The Models page starts with which model to choose and what it costs. The Install page explains the install command. The About page says what you keep if Sub-Sub stops.
|
|
33
|
+
|
|
5
34
|
## 0.13.1 (2026-10-07)
|
|
6
35
|
|
|
7
36
|
- A change that would change nothing (for example, citekeys for items that already have one) is no longer offered for approval. Sub-Sub says why instead.
|
package/dist/bench.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* subsub-bench prepare [--seed 7] pick the tasks (read-only)
|
|
6
6
|
* subsub-bench run [--models a,b] [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
|
|
7
7
|
* (--models sets the models for both task groups.)
|
|
8
|
-
* subsub-bench score
|
|
8
|
+
* subsub-bench score [--no-starbuck] objective checks -> results.md
|
|
9
|
+
* subsub-bench summary [--out FILE] the published figures -> site/src/_data/modeltest.json
|
|
10
|
+
* subsub-bench sheet [--judging ID] [--n 12] [--out DIR] a blind scoring sheet for people
|
|
11
|
+
* subsub-bench agreement --sheet DIR their scores against the AI judge's
|
|
9
12
|
*
|
|
10
13
|
* Results go to ~/Projects/subsub/bench-results/<date>/ (or --dir). Each model gets
|
|
11
14
|
* an anonymous code; key.json maps codes to models. Library writes are recorded
|
|
@@ -215,6 +218,33 @@ function f1(tp, fp, fn) {
|
|
|
215
218
|
return { precision: +p.toFixed(3), recall: +r.toFixed(3), f1: p + r ? +((2 * p * r) / (p + r)).toFixed(3) : 0 };
|
|
216
219
|
}
|
|
217
220
|
const SCORED = /^(topic|method|type)\//;
|
|
221
|
+
/** The item keys that zotero_bakeoff_items gave the model (the server's sample, which can differ from tasks.json). */
|
|
222
|
+
export function servedKeys(calls) {
|
|
223
|
+
const c = calls.filter((x) => x.name === "zotero_bakeoff_items" && !x.isError).at(-1);
|
|
224
|
+
if (!c)
|
|
225
|
+
return [];
|
|
226
|
+
try {
|
|
227
|
+
return (JSON.parse(c.result).items ?? []).map((i) => i.key).filter(Boolean);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The reference that covers the items the model saw: the blind reference (reference.json) or the
|
|
235
|
+
* owner's reviewed tags (library-tags-reference.json). Scoring against a reference for other items
|
|
236
|
+
* gives F1 0 for every model, which looks like a result and is not one.
|
|
237
|
+
*/
|
|
238
|
+
export function pickReference(refs, served) {
|
|
239
|
+
let best;
|
|
240
|
+
for (const [name, ref] of Object.entries(refs)) {
|
|
241
|
+
const keys = served.filter((k) => k in ref);
|
|
242
|
+
const coverage = served.length ? keys.length / served.length : 0;
|
|
243
|
+
if (!best || coverage > best.coverage)
|
|
244
|
+
best = { name, gold: Object.fromEntries(keys.map((k) => [k, ref[k]])), coverage };
|
|
245
|
+
}
|
|
246
|
+
return best && best.coverage > 0 ? best : undefined;
|
|
247
|
+
}
|
|
218
248
|
export function scoreTags(proposals, gold) {
|
|
219
249
|
let tp = 0, fp = 0, fn = 0, exact = 0, missing = 0;
|
|
220
250
|
for (const [key, g] of Object.entries(gold)) {
|
|
@@ -275,7 +305,7 @@ export function starbuckScorer(cfg = loadConfig(), env = process.env) {
|
|
|
275
305
|
};
|
|
276
306
|
};
|
|
277
307
|
}
|
|
278
|
-
export function scoreRun(task, dir, t, index,
|
|
308
|
+
export function scoreRun(task, dir, t, index, refs, starbuck) {
|
|
279
309
|
const events = readEvents(dir);
|
|
280
310
|
const calls = toolCalls(events);
|
|
281
311
|
const entries = benchEntries(events);
|
|
@@ -286,6 +316,7 @@ export function scoreRun(task, dir, t, index, blindRef, starbuck) {
|
|
|
286
316
|
seconds: summary.seconds,
|
|
287
317
|
tool_calls: calls.length,
|
|
288
318
|
tool_errors: calls.filter((c) => c.isError && !/Model test:/.test(c.result)).length,
|
|
319
|
+
refused_calls: refusedCalls(events).refused,
|
|
289
320
|
wrong_mode: entries.filter((e) => e.kind === "wrong_mode").length,
|
|
290
321
|
path_blocked: entries.filter((e) => e.kind === "path_blocked").length,
|
|
291
322
|
tokens_in: summary.stats?.tokens ? summary.stats.tokens.input + summary.stats.tokens.cacheRead : undefined,
|
|
@@ -311,14 +342,21 @@ export function scoreRun(task, dir, t, index, blindRef, starbuck) {
|
|
|
311
342
|
}
|
|
312
343
|
const peeked = calls.filter((c) => ["zotero_get_item", "zotero_find_items"].includes(c.name)).length;
|
|
313
344
|
const status = Object.values(proposals).flat().filter((x) => x.startsWith("status/")).length;
|
|
345
|
+
// Score the items the model was given; a run without that call is scored on what it proposed.
|
|
346
|
+
const served = servedKeys(calls);
|
|
347
|
+
const ref = pickReference(refs, served.length ? served : Object.keys(proposals));
|
|
314
348
|
return {
|
|
315
349
|
...base,
|
|
316
350
|
submitted: !!sub,
|
|
351
|
+
items_served: served.length,
|
|
317
352
|
items_proposed: Object.keys(proposals).length,
|
|
318
353
|
invalid_tags: invalid,
|
|
319
354
|
status_tags: status,
|
|
320
355
|
peeked,
|
|
321
|
-
|
|
356
|
+
reference: ref?.name,
|
|
357
|
+
reference_coverage: ref ? +ref.coverage.toFixed(3) : 0,
|
|
358
|
+
vs_reference: ref ? scoreTags(proposals, ref.gold) : undefined,
|
|
359
|
+
tasks_mismatch: served.length > 0 && served.some((k) => !t.tagging.keys.includes(k)),
|
|
322
360
|
proposals,
|
|
323
361
|
};
|
|
324
362
|
}
|
|
@@ -347,13 +385,22 @@ export function scoreRun(task, dir, t, index, blindRef, starbuck) {
|
|
|
347
385
|
extra_items: [...touched.keys()].filter((k) => !expected.has(k)).length,
|
|
348
386
|
change_calls: changes.length,
|
|
349
387
|
preview_errors: previewErrors.length,
|
|
388
|
+
// Most items changed are not expected: tasks.json describes another state of the library.
|
|
389
|
+
tasks_mismatch: touched.size > 0 && [...touched.keys()].filter((k) => expected.has(k)).length < touched.size / 2,
|
|
350
390
|
proposed: Object.fromEntries(touched),
|
|
351
391
|
};
|
|
352
392
|
}
|
|
353
393
|
if (task === "import") {
|
|
354
394
|
const changes = entries.filter((e) => e.kind === "change" && e.tool === "zotero_import_identifiers");
|
|
355
395
|
const want = [t.import.in_library?.id, ...t.import.new.map((n) => n.id)].filter(Boolean).map((x) => normId(x));
|
|
356
|
-
const sent = new Set(changes.flatMap((c) => (c.input?.identifiers
|
|
396
|
+
const sent = new Set(changes.flatMap((c) => identifierList(c.input?.identifiers).map(normId)));
|
|
397
|
+
// A model may preview the import itself (dry_run) and stop to ask: that counts as sent.
|
|
398
|
+
const asked = !changes.length && calls.some((c) => c.name === "zotero_import_identifiers" && /"dry_run":\s*true/.test(c.result));
|
|
399
|
+
for (const c of calls)
|
|
400
|
+
if (c.name === "zotero_import_identifiers")
|
|
401
|
+
for (const x of identifierList(c.args?.identifiers))
|
|
402
|
+
sent.add(normId(x));
|
|
403
|
+
const fresh = t.import.new.map((n) => normId(n.id));
|
|
357
404
|
const last = changes.at(-1)?.preview ?? {};
|
|
358
405
|
const final = summary.finalText ?? "";
|
|
359
406
|
const dup = t.import.in_library;
|
|
@@ -366,6 +413,10 @@ export function scoreRun(task, dir, t, index, blindRef, starbuck) {
|
|
|
366
413
|
change_calls: changes.length,
|
|
367
414
|
would_import: Array.isArray(last.would_import) ? last.would_import.length : undefined,
|
|
368
415
|
duplicate_reported: reported,
|
|
416
|
+
// Right: every new work sent, and the one already in the library reported (sending it too is fine).
|
|
417
|
+
new_sent: fresh.filter((w) => sent.has(w)).length,
|
|
418
|
+
asked_first: asked,
|
|
419
|
+
import_right: fresh.every((w) => sent.has(w)) && (!dup || reported),
|
|
369
420
|
};
|
|
370
421
|
}
|
|
371
422
|
if (task === "lit_note" || task === "synthesis") {
|
|
@@ -433,6 +484,22 @@ export function scoreRun(task, dir, t, index, blindRef, starbuck) {
|
|
|
433
484
|
}
|
|
434
485
|
return base;
|
|
435
486
|
}
|
|
487
|
+
/** Identifiers as a list: some models send the array as a JSON string, or as one comma-separated string. */
|
|
488
|
+
export function identifierList(v) {
|
|
489
|
+
if (Array.isArray(v))
|
|
490
|
+
return v.map(String);
|
|
491
|
+
if (typeof v !== "string")
|
|
492
|
+
return [];
|
|
493
|
+
try {
|
|
494
|
+
const parsed = JSON.parse(v);
|
|
495
|
+
if (Array.isArray(parsed))
|
|
496
|
+
return parsed.map(String);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
/* not JSON */
|
|
500
|
+
}
|
|
501
|
+
return v.split(/[\s,;]+/).filter(Boolean);
|
|
502
|
+
}
|
|
436
503
|
export function normId(x) {
|
|
437
504
|
const s = x.trim().toLowerCase().replace(/^https?:\/\/(dx\.)?doi\.org\//, "").replace(/^doi:\s*/, "");
|
|
438
505
|
const pm = s.match(/^pmid[:\s]*(\d+)$/);
|
|
@@ -448,12 +515,26 @@ function cell(v) {
|
|
|
448
515
|
return Number.isInteger(v) ? String(v) : v.toFixed(3);
|
|
449
516
|
return String(v);
|
|
450
517
|
}
|
|
518
|
+
/** What makes a column of results meaningless: the tasks or the reference describe other items. */
|
|
519
|
+
export function warnings(rows) {
|
|
520
|
+
const out = [];
|
|
521
|
+
const tag = rows.filter((r) => r.task === "tagging" && r.s.submitted);
|
|
522
|
+
if (tag.some((r) => !r.s.vs_reference))
|
|
523
|
+
out.push("tagging: no reference covers the items the models were given, so F1 is not computed for some runs.");
|
|
524
|
+
else if (tag.some((r) => r.s.reference_coverage < 1))
|
|
525
|
+
out.push("tagging: the reference covers only part of the items the models were given; F1 is over those items only.");
|
|
526
|
+
if (tag.some((r) => r.s.tasks_mismatch))
|
|
527
|
+
out.push("tagging: the server's sample differs from tasks.json (prepare ran again after tasks.json was made).");
|
|
528
|
+
if (rows.some((r) => r.task === "facet_fix" && r.s.tasks_mismatch))
|
|
529
|
+
out.push("facet_fix: the models changed only items that tasks.json does not expect; tasks.json describes another state of the library, so 'fixed' means nothing.");
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
451
532
|
export function renderResults(rows) {
|
|
452
533
|
const out = [`Model test results (${new Date().toISOString().slice(0, 10)}). Objective checks only; the quality judgement is separate.`, ""];
|
|
453
534
|
const cols = {
|
|
454
|
-
tagging: ["submitted", "items_proposed", "vs_reference.f1", "vs_reference.exact", "invalid_tags", "status_tags", "peeked"],
|
|
535
|
+
tagging: ["submitted", "items_proposed", "reference", "reference_coverage", "vs_reference.f1", "vs_reference.exact", "invalid_tags", "status_tags", "peeked"],
|
|
455
536
|
facet_fix: ["expected", "fixed", "extra_items", "change_calls", "preview_errors"],
|
|
456
|
-
import: ["ids_expected", "ids_sent", "extra_ids", "would_import", "duplicate_reported"],
|
|
537
|
+
import: ["import_right", "asked_first", "ids_expected", "ids_sent", "new_sent", "extra_ids", "would_import", "duplicate_reported"],
|
|
457
538
|
lit_note: ["written", "words", "read_fulltext", "cites_itself", "citekeys_unknown"],
|
|
458
539
|
synthesis: ["written", "words", "topic_items", "topic_items_cited", "citekeys_unknown"],
|
|
459
540
|
search: ["written", "dois", "pmids", "ungrounded", "already_in_library", "queued", "searches"],
|
|
@@ -462,6 +543,9 @@ export function renderResults(rows) {
|
|
|
462
543
|
"starbuck.citation_recall", "starbuck.citation_precision"],
|
|
463
544
|
};
|
|
464
545
|
const common = ["seconds", "tool_calls", "tool_errors", "wrong_mode", "tokens_in", "tokens_out", "cost", "error"];
|
|
546
|
+
const warn = warnings(rows);
|
|
547
|
+
if (warn.length)
|
|
548
|
+
out.push("## Warnings", "", ...warn.map((w) => `- ${w}`), "");
|
|
465
549
|
for (const task of Object.keys(cols)) {
|
|
466
550
|
const rs = rows.filter((r) => r.task === task);
|
|
467
551
|
if (!rs.length)
|
|
@@ -476,6 +560,202 @@ export function renderResults(rows) {
|
|
|
476
560
|
}
|
|
477
561
|
return out.join("\n");
|
|
478
562
|
}
|
|
563
|
+
const round = (x, d = 2) => +x.toFixed(d);
|
|
564
|
+
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN);
|
|
565
|
+
/** Tool calls that the tool refused because the arguments did not match its schema. */
|
|
566
|
+
export function refusedCalls(events) {
|
|
567
|
+
let refused = 0;
|
|
568
|
+
let calls = 0;
|
|
569
|
+
for (const e of events) {
|
|
570
|
+
if (e.type === "tool_execution_start")
|
|
571
|
+
calls++;
|
|
572
|
+
if (e.type === "tool_execution_end" && e.isError && /Validation failed/.test(resultText(e)))
|
|
573
|
+
refused++;
|
|
574
|
+
}
|
|
575
|
+
return { refused, calls };
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* The figures on the site's Models page, computed from the committed runs and judgements, so
|
|
579
|
+
* that anyone can check them: subsub-bench summary writes them to site/src/_data/modeltest.json.
|
|
580
|
+
*/
|
|
581
|
+
export function summarise(root) {
|
|
582
|
+
const tests = JSON.parse(readFileSync(join(root, "published.json"), "utf8")).tests;
|
|
583
|
+
const out = {};
|
|
584
|
+
for (const test of tests) {
|
|
585
|
+
const judged = JSON.parse(readFileSync(join(root, "judging", test.judging, "scores.json"), "utf8")).scores;
|
|
586
|
+
const jkey = JSON.parse(readFileSync(join(root, "judging", test.judging, "key.json"), "utf8"));
|
|
587
|
+
const rows = [];
|
|
588
|
+
for (const dir of test.runs)
|
|
589
|
+
for (const r of JSON.parse(readFileSync(join(root, dir, "results.json"), "utf8")))
|
|
590
|
+
rows.push({ ...r, dir, model: r.model.split("/").pop() });
|
|
591
|
+
const names = test.models ?? [...new Set(rows.map((r) => r.model))];
|
|
592
|
+
const models = {};
|
|
593
|
+
for (const m of names) {
|
|
594
|
+
const all = rows.filter((r) => r.model === m && (!test.role || r.role === test.role) && (!test.tasks || test.tasks.includes(r.task)));
|
|
595
|
+
// A model tested in several folders (the default, beside the EU models) counts once: the first folder that has it.
|
|
596
|
+
const mine = test.runs.length > 1 && !test.role ? all.filter((r) => r.dir === all[0]?.dir) : all;
|
|
597
|
+
const tag = mine.filter((r) => r.task === "tagging" && r.s.vs_reference).map((r) => r.s.vs_reference.f1);
|
|
598
|
+
const imp = mine.filter((r) => r.task === "import");
|
|
599
|
+
const scores = judged.filter((j) => jkey[j.label]?.model === m);
|
|
600
|
+
const byTask = {};
|
|
601
|
+
for (const t of [...new Set(scores.map((j) => j.task))])
|
|
602
|
+
byTask[t] = round(mean(scores.filter((j) => j.task === t).map((j) => j.score)));
|
|
603
|
+
// From results.json only: the run logs hold passages of full texts and stay private.
|
|
604
|
+
const refused = mine.reduce((a, r) => a + (r.s.refused_calls ?? 0), 0);
|
|
605
|
+
const calls = mine.reduce((a, r) => a + (r.s.tool_calls ?? 0), 0);
|
|
606
|
+
const cost = mine.reduce((a, r) => a + (r.s.cost ?? 0), 0);
|
|
607
|
+
models[m] = {
|
|
608
|
+
works: mine.some((r) => r.s.tool_calls > 0),
|
|
609
|
+
research: scores.length ? round(mean(scores.map((j) => j.score))) : null,
|
|
610
|
+
research_low: scores.length ? Math.min(...scores.map((j) => j.score)) : null,
|
|
611
|
+
research_high: scores.length ? Math.max(...scores.map((j) => j.score)) : null,
|
|
612
|
+
research_notes: scores.length,
|
|
613
|
+
research_by_task: byTask,
|
|
614
|
+
invented_refs: scores.reduce((a, j) => a + (j.invented_refs ?? 0), 0),
|
|
615
|
+
f1: tag.length ? round(mean(tag)) : null,
|
|
616
|
+
f1_low: tag.length ? round(Math.min(...tag)) : null,
|
|
617
|
+
f1_high: tag.length ? round(Math.max(...tag)) : null,
|
|
618
|
+
imports: imp.length,
|
|
619
|
+
imports_right: imp.filter((r) => r.s.import_right).length,
|
|
620
|
+
imports_asked: imp.filter((r) => r.s.import_right && r.s.asked_first).length,
|
|
621
|
+
// One import: "right", "asked" (previewed, then asked before applying), "one" or "none" of the identifiers sent.
|
|
622
|
+
import: imp.length !== 1 ? undefined : imp[0].s.import_right ? (imp[0].s.asked_first ? "asked" : "right") : imp[0].s.ids_sent ? "one" : "none",
|
|
623
|
+
refused_calls: refused,
|
|
624
|
+
tool_calls: calls,
|
|
625
|
+
free: test.free?.includes(m) ?? false,
|
|
626
|
+
cost: round(cost),
|
|
627
|
+
cost_per_task: mine.length ? round(cost / mine.length, 3) : null,
|
|
628
|
+
minutes_per_task: mine.length ? round(mean(mine.map((r) => r.s.seconds ?? 0)) / 60, 1) : null,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
// The page lists the models by research score (the models that did not work last), then by name.
|
|
632
|
+
const order = Object.keys(models).sort((x, y) => Number(models[y].works) - Number(models[x].works) || (models[y].research ?? 0) - (models[x].research ?? 0) || x.localeCompare(y));
|
|
633
|
+
const f1s = order.filter((m) => models[m].f1 !== null).map((m) => models[m].f1);
|
|
634
|
+
out[test.id] = { runs: test.runs, judging: test.judging, order, f1_low: f1s.length ? Math.min(...f1s) : null, f1_high: f1s.length ? Math.max(...f1s) : null, models };
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
// ---------------------------------------------------------------- calibration of the judge
|
|
639
|
+
/** The file a run wrote for a research task (the note, the synthesis, the search list or the review). */
|
|
640
|
+
export function outputFile(runDir) {
|
|
641
|
+
if (!existsSync(runDir))
|
|
642
|
+
return undefined;
|
|
643
|
+
const md = readdirSync(runDir).filter((f) => f.endsWith(".md")).sort();
|
|
644
|
+
return md.length ? join(runDir, md[0]) : undefined;
|
|
645
|
+
}
|
|
646
|
+
/** A small random generator with a seed, so that a sheet can be made again the same way. */
|
|
647
|
+
function seeded(seed) {
|
|
648
|
+
let x = seed >>> 0 || 1;
|
|
649
|
+
return () => ((x = (x * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* A blind scoring sheet for people: n outputs from a judging, balanced over the tasks, under new
|
|
653
|
+
* labels, without the judge's scores or the model names. sheet-key.json maps the new labels to the
|
|
654
|
+
* judge's; keep it away from the people who score.
|
|
655
|
+
*/
|
|
656
|
+
export function makeSheet(root, judging, outDir, n = 12, seed = 1) {
|
|
657
|
+
const judged = JSON.parse(readFileSync(join(root, "judging", judging, "scores.json"), "utf8")).scores;
|
|
658
|
+
const jkey = JSON.parse(readFileSync(join(root, "judging", judging, "key.json"), "utf8"));
|
|
659
|
+
const rand = seeded(seed);
|
|
660
|
+
const tasks = [...new Set(judged.map((j) => j.task))];
|
|
661
|
+
const pick = [];
|
|
662
|
+
const pools = tasks.map((t) => judged.filter((j) => j.task === t && outputFile(join(root, jkey[j.label].run, "runs", "", "")) !== null).sort(() => rand() - 0.5));
|
|
663
|
+
for (let i = 0; pick.length < n && pools.some((p) => p.length); i++) {
|
|
664
|
+
const p = pools[i % pools.length];
|
|
665
|
+
const j = p.shift();
|
|
666
|
+
if (!j)
|
|
667
|
+
continue;
|
|
668
|
+
const [dir, code] = jkey[j.label].run.split("/");
|
|
669
|
+
if (outputFile(join(root, dir, "runs", code, j.task)))
|
|
670
|
+
pick.push(j);
|
|
671
|
+
}
|
|
672
|
+
mkdirSync(outDir, { recursive: true });
|
|
673
|
+
// A judge's label can name a run with several tasks: the key keeps the label and the task.
|
|
674
|
+
const key = {};
|
|
675
|
+
const rows = ["label,task,score,invented_refs,notes"];
|
|
676
|
+
const used = new Set();
|
|
677
|
+
for (const j of pick.sort(() => rand() - 0.5)) {
|
|
678
|
+
let label;
|
|
679
|
+
do
|
|
680
|
+
label = `H${Math.floor(100 + rand() * 900)}`;
|
|
681
|
+
while (used.has(label));
|
|
682
|
+
used.add(label);
|
|
683
|
+
key[label] = { label: j.label, task: j.task };
|
|
684
|
+
const [dir, code] = jkey[j.label].run.split("/");
|
|
685
|
+
const file = outputFile(join(root, dir, "runs", code, j.task));
|
|
686
|
+
writeFileSync(join(outDir, `${label}.md`), readFileSync(file, "utf8"));
|
|
687
|
+
rows.push(`${label},${j.task},,,`);
|
|
688
|
+
}
|
|
689
|
+
writeFileSync(join(outDir, "scores.csv"), `${rows.join("\n")}\n`);
|
|
690
|
+
writeFileSync(join(outDir, "sheet-key.json"), JSON.stringify({ judging, key }, null, 1));
|
|
691
|
+
writeFileSync(join(outDir, "README.md"), [
|
|
692
|
+
"# Scoring sheet",
|
|
693
|
+
"",
|
|
694
|
+
"Score each file from 1 to 5 (halves allowed), without looking at the other scorers' sheets.",
|
|
695
|
+
"",
|
|
696
|
+
"- 5: every claim has a source the note read; the items are covered; quotes and numbers are right.",
|
|
697
|
+
"- 3: mostly right, with claims without a source or items left out.",
|
|
698
|
+
"- 1: wrong paper, invented references, or claims the sources do not make.",
|
|
699
|
+
"",
|
|
700
|
+
"Count invented references: a citekey not in the library, or a DOI or PMID that does not exist.",
|
|
701
|
+
"Write your scores in a copy of scores.csv named scores-<your initials>.csv. Do not open sheet-key.json.",
|
|
702
|
+
"Then: subsub-bench agreement --sheet <this folder>",
|
|
703
|
+
"",
|
|
704
|
+
].join("\n"));
|
|
705
|
+
return pick.length;
|
|
706
|
+
}
|
|
707
|
+
/** Quadratic-weighted kappa on the half-point scale 1, 1.5, ... 5 (0 = chance agreement, 1 = the same scores). */
|
|
708
|
+
export function weightedKappa(a, b) {
|
|
709
|
+
const k = 9;
|
|
710
|
+
const idx = (x) => Math.min(k - 1, Math.max(0, Math.round((x - 1) * 2)));
|
|
711
|
+
const obs = Array.from({ length: k }, () => new Array(k).fill(0));
|
|
712
|
+
for (let i = 0; i < a.length; i++)
|
|
713
|
+
obs[idx(a[i])][idx(b[i])]++;
|
|
714
|
+
const ra = obs.map((r) => r.reduce((x, y) => x + y, 0));
|
|
715
|
+
const cb = obs[0].map((_, j) => obs.reduce((x, r) => x + r[j], 0));
|
|
716
|
+
let num = 0;
|
|
717
|
+
let den = 0;
|
|
718
|
+
for (let i = 0; i < k; i++)
|
|
719
|
+
for (let j = 0; j < k; j++) {
|
|
720
|
+
const w = ((i - j) / (k - 1)) ** 2;
|
|
721
|
+
num += w * obs[i][j];
|
|
722
|
+
den += (w * ra[i] * cb[j]) / a.length;
|
|
723
|
+
}
|
|
724
|
+
return den ? round(1 - num / den, 3) : 1;
|
|
725
|
+
}
|
|
726
|
+
/** Agreement between each scorer and the judge, and between scorers. */
|
|
727
|
+
export function agreement(root, sheetDir) {
|
|
728
|
+
const { judging, key } = JSON.parse(readFileSync(join(sheetDir, "sheet-key.json"), "utf8"));
|
|
729
|
+
const judged = JSON.parse(readFileSync(join(root, "judging", judging, "scores.json"), "utf8")).scores;
|
|
730
|
+
const raters = { judge: {} };
|
|
731
|
+
for (const [h, j] of Object.entries(key)) {
|
|
732
|
+
const s = judged.find((x) => x.label === j.label && x.task === j.task)?.score;
|
|
733
|
+
if (s !== undefined)
|
|
734
|
+
raters.judge[h] = s;
|
|
735
|
+
}
|
|
736
|
+
for (const f of readdirSync(sheetDir).filter((f) => /^scores-.+\.csv$/.test(f))) {
|
|
737
|
+
const name = f.replace(/^scores-|\.csv$/g, "");
|
|
738
|
+
raters[name] = {};
|
|
739
|
+
for (const line of readFileSync(join(sheetDir, f), "utf8").split("\n").slice(1)) {
|
|
740
|
+
const [label, , score] = line.split(",");
|
|
741
|
+
if (label && score && !Number.isNaN(Number(score)))
|
|
742
|
+
raters[name][label.trim()] = Number(score);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const names = Object.keys(raters);
|
|
746
|
+
const out = [];
|
|
747
|
+
for (let i = 0; i < names.length; i++)
|
|
748
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
749
|
+
const common = Object.keys(raters[names[i]]).filter((l) => l in raters[names[j]]);
|
|
750
|
+
if (!common.length)
|
|
751
|
+
continue;
|
|
752
|
+
const x = common.map((l) => raters[names[i]][l]);
|
|
753
|
+
const y = common.map((l) => raters[names[j]][l]);
|
|
754
|
+
const d = x.map((v, k) => Math.abs(v - y[k]));
|
|
755
|
+
out.push({ a: names[i], b: names[j], n: common.length, mean_abs_diff: round(mean(d)), within_half: round(d.filter((v) => v <= 0.5).length / d.length), kappa: weightedKappa(x, y) });
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
479
759
|
// ---------------------------------------------------------------- CLI
|
|
480
760
|
function arg(argv, name) {
|
|
481
761
|
const i = argv.indexOf(`--${name}`);
|
|
@@ -576,22 +856,56 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
576
856
|
const t = JSON.parse(readFileSync(join(dir, "tasks.json"), "utf8"));
|
|
577
857
|
const index = JSON.parse(readFileSync(join(dir, "library-index.json"), "utf8"));
|
|
578
858
|
const key = JSON.parse(readFileSync(join(dir, "key.json"), "utf8"));
|
|
579
|
-
const
|
|
859
|
+
const refs = {};
|
|
860
|
+
for (const [name, file] of [["blind", "reference.json"], ["library", "library-tags-reference.json"]])
|
|
861
|
+
if (existsSync(join(dir, file)))
|
|
862
|
+
refs[name] = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
580
863
|
const rows = [];
|
|
581
|
-
|
|
864
|
+
// --no-starbuck keeps the reference checks of the last score (they are slow and need the network).
|
|
865
|
+
const previous = argv.includes("--no-starbuck") && existsSync(join(dir, "results.json")) ? JSON.parse(readFileSync(join(dir, "results.json"), "utf8")) : [];
|
|
866
|
+
const scorer = argv.includes("--no-starbuck") ? undefined : starbuckScorer(cfg);
|
|
582
867
|
for (const code of Object.keys(key).sort()) {
|
|
583
868
|
for (const role of ["librarian", "researcher"])
|
|
584
869
|
for (const task of TASKS[role]) {
|
|
585
870
|
const d = join(dir, "runs", code, task);
|
|
586
871
|
if (!existsSync(join(d, "summary.json")))
|
|
587
872
|
continue;
|
|
588
|
-
|
|
873
|
+
const kept = previous.find((r) => r.model === key[code] && r.task === task)?.s.starbuck;
|
|
874
|
+
rows.push({ model: key[code], role, task, s: scoreRun(task, d, t, index, refs, scorer ?? (kept ? () => kept : undefined)) });
|
|
589
875
|
}
|
|
590
876
|
}
|
|
591
877
|
writeFileSync(join(dir, "results.json"), JSON.stringify(rows, null, 1));
|
|
592
878
|
writeFileSync(join(dir, "results.md"), renderResults(rows));
|
|
879
|
+
for (const w of warnings(rows))
|
|
880
|
+
console.warn(`Warning: ${w}`);
|
|
593
881
|
console.log(`Wrote ${join(dir, "results.md")}`);
|
|
594
882
|
return;
|
|
595
883
|
}
|
|
596
|
-
|
|
884
|
+
if (cmd === "summary") {
|
|
885
|
+
const root = join(PACKAGE_DIR, "bench-results");
|
|
886
|
+
const file = arg(argv, "out") ?? join(PACKAGE_DIR, "site", "src", "_data", "modeltest.json");
|
|
887
|
+
writeFileSync(file, `${JSON.stringify(summarise(root), null, 1)}\n`);
|
|
888
|
+
console.log(`Wrote ${file}`);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (cmd === "sheet") {
|
|
892
|
+
const out = expand(arg(argv, "out") ?? join(process.cwd(), "scoring-sheet"));
|
|
893
|
+
const n = makeSheet(join(PACKAGE_DIR, "bench-results"), arg(argv, "judging") ?? "2026-10-07-repeats", out, Number(arg(argv, "n") ?? 12), Number(arg(argv, "seed") ?? 1));
|
|
894
|
+
console.log(`Wrote a sheet of ${n} outputs to ${out}. Give the scorers the .md files, README.md and scores.csv, not sheet-key.json.`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (cmd === "agreement") {
|
|
898
|
+
const sheet = arg(argv, "sheet");
|
|
899
|
+
if (!sheet)
|
|
900
|
+
throw new Error("Give the sheet folder: subsub-bench agreement --sheet DIR");
|
|
901
|
+
const rows = agreement(join(PACKAGE_DIR, "bench-results"), expand(sheet));
|
|
902
|
+
writeFileSync(join(expand(sheet), "agreement.json"), JSON.stringify(rows, null, 1));
|
|
903
|
+
console.log("| a | b | items | mean difference | within 0.5 | weighted kappa |\n|---|---|---|---|---|---|");
|
|
904
|
+
for (const r of rows)
|
|
905
|
+
console.log(`| ${r.a} | ${r.b} | ${r.n} | ${r.mean_abs_diff} | ${r.within_half} | ${r.kappa} |`);
|
|
906
|
+
if (!rows.length)
|
|
907
|
+
console.log("No scores yet: add scores-<initials>.csv files to the sheet folder.");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
console.log("Usage: subsub-bench prepare | run [--models a,b] [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--no-starbuck] | summary | sheet [--judging ID] [--n 12] [--out DIR] | agreement --sheet DIR [--dir DIR]");
|
|
597
911
|
}
|
package/dist/notes.js
CHANGED
|
@@ -40,3 +40,93 @@ export function applyEdits(current, edits) {
|
|
|
40
40
|
}
|
|
41
41
|
return out;
|
|
42
42
|
}
|
|
43
|
+
// ---------------------------------------------------------------- quotes against the full text
|
|
44
|
+
/** The Zotero item key in a note's front matter (zotero: zotero://select/library/items/KEY). */
|
|
45
|
+
export function itemKeyOf(text) {
|
|
46
|
+
const front = frontMatter(text);
|
|
47
|
+
const z = front ? field(front, "zotero") : undefined;
|
|
48
|
+
return z ? /items\/([A-Z0-9]{8})\b/.exec(z)?.[1] : undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Direct quotes in a note: text in double quotes, and blockquote lines, of 25 characters or more.
|
|
52
|
+
* Shorter quotes are usually terms, not passages, and are not checked.
|
|
53
|
+
*/
|
|
54
|
+
export function quotesIn(text) {
|
|
55
|
+
const body = text.replace(FRONT, "");
|
|
56
|
+
const out = new Set();
|
|
57
|
+
// An opening mark follows a space or a bracket and a closing mark precedes one, so the text
|
|
58
|
+
// between one quote and the next ("a" (p. 3); then "b") is not taken for a quote.
|
|
59
|
+
for (const m of body.matchAll(/(?:^|[\s(\[—–])"([^"\s][^"\n]{23,598}[^"\s])"(?=[\s.,;:)\]!?]|$)/gm))
|
|
60
|
+
out.add(m[1].trim());
|
|
61
|
+
for (const m of body.matchAll(/“([^“”\n]{25,600})”/g))
|
|
62
|
+
out.add(m[1].trim());
|
|
63
|
+
for (const m of body.matchAll(/^>[ \t]*(.{25,})$/gm)) {
|
|
64
|
+
// A blockquote may end with its source: "... (p. 12)" or "... [@smith2026, p. 12]".
|
|
65
|
+
const q = m[1].replace(/\s*(?:\((?:p|pp)\.[^)]*\)|\[@[^\]]*\]|[-—–]\s*p\.?\s*\d+.*)\s*$/i, "").replace(/^["“]|["”]$/g, "").trim();
|
|
66
|
+
if (q.length >= 25)
|
|
67
|
+
out.add(q);
|
|
68
|
+
}
|
|
69
|
+
return [...out];
|
|
70
|
+
}
|
|
71
|
+
/** Letters and digits only, lower case: PDF text breaks lines, hyphenates words and changes quote marks. */
|
|
72
|
+
export function matchable(s) {
|
|
73
|
+
return s
|
|
74
|
+
.normalize("NFKD")
|
|
75
|
+
.replace(/[̀-ͯ]/g, "")
|
|
76
|
+
.replace(/(\w)-\s*\n\s*(\w)/g, "$1$2")
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The quotes that are not in the full text. A quote with an ellipsis is checked in parts;
|
|
82
|
+
* square brackets (an editor's insertion) are left out of the comparison.
|
|
83
|
+
*/
|
|
84
|
+
export function missingQuotes(note, fullText, already = []) {
|
|
85
|
+
const source = matchable(fullText);
|
|
86
|
+
const old = new Set(already);
|
|
87
|
+
return quotesIn(note)
|
|
88
|
+
.filter((q) => !old.has(q))
|
|
89
|
+
.filter((q) => {
|
|
90
|
+
const parts = q.split(/\s*(?:\.\.\.|…|\[\s*(?:\.\.\.|…)\s*\])\s*/).map((p) => p.replace(/\[[^\]]*\]/g, " "));
|
|
91
|
+
return parts.filter((p) => matchable(p).length >= 12).some((p) => !appearsIn(source, p));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a passage is in the (matchable) source: whole, or as groups of three words in order with
|
|
96
|
+
* short gaps, because a PDF's running header or footer can fall in the middle of a sentence.
|
|
97
|
+
*/
|
|
98
|
+
export function appearsIn(source, passage, gap = 300) {
|
|
99
|
+
if (source.includes(matchable(passage)))
|
|
100
|
+
return true;
|
|
101
|
+
const words = passage.split(/\s+/).map(matchable).filter(Boolean);
|
|
102
|
+
const groups = [];
|
|
103
|
+
for (let i = 0; i < words.length; i += 3)
|
|
104
|
+
groups.push(words.slice(i, i + 3).join(""));
|
|
105
|
+
if (groups.length < 2)
|
|
106
|
+
return false;
|
|
107
|
+
for (let start = source.indexOf(groups[0]), tries = 0; start >= 0 && tries < 50; start = source.indexOf(groups[0], start + 1), tries++) {
|
|
108
|
+
let end = start + groups[0].length;
|
|
109
|
+
let ok = true;
|
|
110
|
+
for (const g of groups.slice(1)) {
|
|
111
|
+
const at = source.indexOf(g, end);
|
|
112
|
+
if (at < 0 || at - end > gap) {
|
|
113
|
+
ok = false;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
end = at + g.length;
|
|
117
|
+
}
|
|
118
|
+
if (ok)
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
export function quoteProblem(missing) {
|
|
124
|
+
if (!missing.length)
|
|
125
|
+
return undefined;
|
|
126
|
+
const shown = missing.slice(0, 5).map((q) => `- "${q.length > 160 ? `${q.slice(0, 160)}...` : q}"`);
|
|
127
|
+
return [
|
|
128
|
+
`${missing.length} quote(s) in this note are not in the item's full text:`,
|
|
129
|
+
...shown,
|
|
130
|
+
"Copy each quote word for word from the full text (zotero_get_fulltext), or remove the quote marks and say it in your own words. Then write the note again.",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
package/dist/profiles.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Scholar. A profile is the user's own choice (/profile changes it), not a lock.
|
|
5
5
|
*
|
|
6
6
|
* - reader: explains each step; reading notes are quotes with page numbers and
|
|
7
|
-
* questions; no summaries or syntheses;
|
|
7
|
+
* questions; no summaries or syntheses; /lit gives a reading list;
|
|
8
|
+
* no bulk library changes.
|
|
8
9
|
* - scholar: literature notes, syntheses, searches, alerts; all library changes.
|
|
9
10
|
* - author: scholar plus manuscript work: citation check, bibliography, comments
|
|
10
11
|
* on the argument. Does not write the manuscript's paragraphs.
|
|
@@ -16,15 +17,16 @@ export const PROFILE_SPECS = {
|
|
|
16
17
|
reader: {
|
|
17
18
|
name: "reader",
|
|
18
19
|
label: "Reader",
|
|
19
|
-
summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
|
|
20
|
+
summary: "explains each step; reading notes are quotes and questions; /lit gives a reading list; no summaries; no bulk library changes",
|
|
20
21
|
without: [...BULK_LIBRARY, ...MANUSCRIPT],
|
|
21
|
-
withoutPrompts: ["
|
|
22
|
+
withoutPrompts: ["compare", "review", "digest"],
|
|
22
23
|
batch: 10,
|
|
23
24
|
rules: [
|
|
24
25
|
"Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
|
|
25
26
|
"- Before each step, say in one or two lines what you will do and why.",
|
|
26
27
|
"- A literature note is a reading note: the full reference, then direct quotes with page numbers (or section names), and under each quote one question for {{user}} to answer. Do not write summaries, syntheses, conclusions or paragraphs that {{user}} could hand in as their own text. When a prompt or the shared rules ask for a literature note or a synthesis, make a reading note instead and say so.",
|
|
27
28
|
"- For searches, list works with one line each on what they study, not on what they conclude.",
|
|
29
|
+
"- /lit gives a reading list, not a review: plan, search, screen and read as the prompt says, then write the note in the Reader form that the prompt gives (no answer, no agreement or disagreement, no conclusions).",
|
|
28
30
|
"- If {{user}} asks for a summary, a synthesis or text for an assignment, say that the Reader profile does not write these, and offer quotes and questions. {{User}} can change the profile with /profile.",
|
|
29
31
|
"- Tag review notes: at most 10 items. Explain each proposed tag in the Reason column.",
|
|
30
32
|
].join("\n"),
|
package/dist/roles.js
CHANGED
|
@@ -97,3 +97,22 @@ export function gateKind(toolName, input) {
|
|
|
97
97
|
return "path";
|
|
98
98
|
return null;
|
|
99
99
|
}
|
|
100
|
+
/** Tools that send a query to an outside service (PubMed, Europe PMC, OpenAlex, Crossref, a web search). */
|
|
101
|
+
const OUTBOUND = new Set(["scholar_search_pubmed", "scholar_search_openalex", "scholar_search_europepmc", "scholar_search_multi", "zotero_find_reference", "zotero_web_search"]);
|
|
102
|
+
export const MAX_QUERY_CHARS = 1500;
|
|
103
|
+
/**
|
|
104
|
+
* Why an outside search is refused: a query is search terms, so a query that is long or holds
|
|
105
|
+
* several lines is text from a note, a full text or the library, sent where it should not go
|
|
106
|
+
* (for example because a document asked for it).
|
|
107
|
+
*/
|
|
108
|
+
export function outboundProblem(toolName, input) {
|
|
109
|
+
if (!OUTBOUND.has(toolName))
|
|
110
|
+
return undefined;
|
|
111
|
+
const queries = [input.query, ...(Array.isArray(input.queries) ? input.queries : [])].filter((q) => typeof q === "string");
|
|
112
|
+
for (const q of queries) {
|
|
113
|
+
if (q.length > MAX_QUERY_CHARS || q.split("\n").length > 3) {
|
|
114
|
+
return `This search query is ${q.length} characters on ${q.split("\n").length} lines. A query is search terms; send no passages of notes, full texts or library records to an outside service. Shorten it to the search terms.`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|