paperlint 14.1.0 → 14.2.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/dist/skills/build-label-page/scripts/label-page-core.d.ts +43 -30
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +15 -1
- package/skills/build-label-page/SKILL.md.spec.ts +14 -0
- package/skills/build-label-page/references/closed-choice-generator.md +42 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +309 -0
- package/skills/build-label-page/scripts/label-page-core.js +206 -58
- package/skills/build-label-page/scripts/label-page-core.test.ts +24 -9
- package/skills/build-label-page/scripts/label-page-core.ts +333 -77
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +205 -18
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
+
import * as R from "remeda";
|
|
10
11
|
import { z } from "zod";
|
|
11
12
|
import { describeRule, } from "./rule-descriptions.js";
|
|
12
13
|
import { applyPreset } from "./label-page-presets.js";
|
|
@@ -97,6 +98,9 @@ const readJson = (path) => {
|
|
|
97
98
|
// prettier-ignore
|
|
98
99
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"];
|
|
99
100
|
const PRIVATE_FIELDS = ["source_id"];
|
|
101
|
+
// Who of the page's `answerers` an item is shown to: tells the page whom to show it, nothing about
|
|
102
|
+
// the answer or the source.
|
|
103
|
+
const ANSWERERS_FIELD = "answerers";
|
|
100
104
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
101
105
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
102
106
|
const RULES_FIELD = "rules";
|
|
@@ -121,6 +125,7 @@ const ALLOWED_FIELDS = [
|
|
|
121
125
|
UNIT_FIELD,
|
|
122
126
|
READINGS_FIELD,
|
|
123
127
|
...BLOCK_FIELDS,
|
|
128
|
+
ANSWERERS_FIELD,
|
|
124
129
|
...PRIVATE_FIELDS,
|
|
125
130
|
];
|
|
126
131
|
const FAMILIARITY = [
|
|
@@ -147,6 +152,11 @@ const RESERVED_NAMES = [
|
|
|
147
152
|
"__lookupSetter__",
|
|
148
153
|
];
|
|
149
154
|
const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
|
|
155
|
+
/**
|
|
156
|
+
* With `answerers`, a document's id is `<answerer>~<id>`: `~` is outside NAME_PATTERN, so no
|
|
157
|
+
* answerer's name, item id, group or practice id can hold it, and the id splits at its first `~`.
|
|
158
|
+
*/
|
|
159
|
+
const SEP = "~";
|
|
150
160
|
const SOURCES = ["page", "chat"];
|
|
151
161
|
const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
152
162
|
const string = (required = true, pattern, why) => pattern === undefined
|
|
@@ -275,6 +285,7 @@ const itemSchema = (kind) => obj({
|
|
|
275
285
|
string(f === "group" || f === "body"),
|
|
276
286
|
])),
|
|
277
287
|
...cardFields(kind),
|
|
288
|
+
[ANSWERERS_FIELD]: array(string(true, NAME_PATTERN), false, 1),
|
|
278
289
|
}, false);
|
|
279
290
|
// `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
|
|
280
291
|
// reads as decoration, not meaning (the build-label-page look test refuses one).
|
|
@@ -310,6 +321,10 @@ const configSchema = (kind) => obj({
|
|
|
310
321
|
seed: integer(),
|
|
311
322
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
312
323
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
324
|
+
// Two or more people answer the same page, each under their own name (doc ids `<name>~<id>`).
|
|
325
|
+
answerers: array(string(true, NAME_PATTERN), false, 2),
|
|
326
|
+
// An item's answer saves only once the item has been on screen this many seconds.
|
|
327
|
+
min_seconds: integer(false),
|
|
313
328
|
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
314
329
|
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
315
330
|
familiarity: boolean(false),
|
|
@@ -495,8 +510,35 @@ function checkPractice(cfg) {
|
|
|
495
510
|
fail(`${where}.${missing}: every question needs its expected answer`);
|
|
496
511
|
});
|
|
497
512
|
}
|
|
513
|
+
/** The first entry of `names` that repeats an earlier one, refused by its path. */
|
|
514
|
+
function checkDistinct(names, where) {
|
|
515
|
+
const at = names.findIndex((x, i) => names.indexOf(x) !== i);
|
|
516
|
+
if (at !== -1)
|
|
517
|
+
fail(`${where}[${String(at)}]: ${repr(names[at])} is repeated`);
|
|
518
|
+
}
|
|
519
|
+
/** Each answerer once; an item's own list a subset of the page's; a dwell of a second or more. */
|
|
520
|
+
function checkAnswerers(cfg) {
|
|
521
|
+
if ((cfg.min_seconds ?? 1) < 1)
|
|
522
|
+
fail(`min_seconds: ${repr(cfg.min_seconds)} — must be 1 or more`);
|
|
523
|
+
const all = cfg.answerers;
|
|
524
|
+
if (all !== undefined)
|
|
525
|
+
checkDistinct(all, "answerers");
|
|
526
|
+
cfg.items.forEach((item, n) => {
|
|
527
|
+
const where = `items[${String(n)}].answerers`;
|
|
528
|
+
const own = item.answerers;
|
|
529
|
+
if (own === undefined)
|
|
530
|
+
return;
|
|
531
|
+
if (all === undefined)
|
|
532
|
+
return fail(`${where}: the page has no \`answerers\` — add the page's list, or take this out`);
|
|
533
|
+
const k = own.findIndex((who) => !R.isIncludedIn(who, all));
|
|
534
|
+
if (k !== -1)
|
|
535
|
+
fail(`${where}[${String(k)}]: ${repr(own[k])} is not in \`answerers\` (${all.join(", ")})`);
|
|
536
|
+
checkDistinct(own, where);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
498
539
|
function checkConfig(cfg) {
|
|
499
540
|
checkGroups(cfg);
|
|
541
|
+
checkAnswerers(cfg);
|
|
500
542
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
501
543
|
fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`);
|
|
502
544
|
}
|
|
@@ -746,6 +788,8 @@ const DEFAULT_LABELS = {
|
|
|
746
788
|
repeat: "Card {index} of {of}",
|
|
747
789
|
practice_example: "",
|
|
748
790
|
};
|
|
791
|
+
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
792
|
+
const twoKeys = (cfg) => R.pickBy({ answerers: cfg.answerers, min_seconds: cfg.min_seconds }, R.isDefined);
|
|
749
793
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
750
794
|
function payloadOf(cfg, page, described) {
|
|
751
795
|
const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
|
|
@@ -759,18 +803,22 @@ function payloadOf(cfg, page, described) {
|
|
|
759
803
|
: "",
|
|
760
804
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
761
805
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
806
|
+
...twoKeys(cfg),
|
|
762
807
|
questions: cfg.questions,
|
|
763
808
|
practice: {
|
|
764
809
|
round: practiceRound(cfg),
|
|
765
810
|
items: practiceShown(cfg, described),
|
|
766
811
|
},
|
|
767
|
-
items: page.items.map((i) => (
|
|
768
|
-
id: page.idOf(i),
|
|
769
|
-
...shownFields(i),
|
|
770
|
-
...rulesShown(i, described),
|
|
771
|
-
})),
|
|
812
|
+
items: page.items.map((i) => itemShown(i, page.idOf(i), described)),
|
|
772
813
|
};
|
|
773
814
|
}
|
|
815
|
+
/** One item as its card shows it, and whom it is shown to when not every answerer. */
|
|
816
|
+
const itemShown = (i, id, described) => ({
|
|
817
|
+
id,
|
|
818
|
+
...shownFields(i),
|
|
819
|
+
...rulesShown(i, described),
|
|
820
|
+
...R.pickBy({ answerers: i.answerers }, R.isDefined),
|
|
821
|
+
});
|
|
774
822
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
775
823
|
function idsOf(cfg, page) {
|
|
776
824
|
const shown = new Set(page.items.map(page.idOf));
|
|
@@ -781,15 +829,14 @@ function idsOf(cfg, page) {
|
|
|
781
829
|
]),
|
|
782
830
|
...cfg.items.map((i) => [i.source_id, i.group]),
|
|
783
831
|
]);
|
|
832
|
+
const bySource = R.indexBy(cfg.items, (i) => i.source_id);
|
|
784
833
|
const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
|
|
785
|
-
return
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
},
|
|
792
|
-
]));
|
|
834
|
+
return R.pullObject(byId, ([, nid]) => nid, ([sid, nid]) => ({
|
|
835
|
+
source_id: sid,
|
|
836
|
+
group: String(groupOf.get(sid)),
|
|
837
|
+
on_page: shown.has(nid),
|
|
838
|
+
...R.pickBy({ answerers: bySource[sid]?.answerers }, R.isDefined),
|
|
839
|
+
}));
|
|
793
840
|
}
|
|
794
841
|
/** Every round of a key's practice; a key from before `rounds` has only its own round. */
|
|
795
842
|
const roundsIn = (p) => p.rounds ?? { [String(p.round)]: p.expected };
|
|
@@ -806,6 +853,7 @@ function keyOf(cfg, page, earlier) {
|
|
|
806
853
|
return {
|
|
807
854
|
study: cfg.study,
|
|
808
855
|
seed: cfg.seed,
|
|
856
|
+
...twoKeys(cfg),
|
|
809
857
|
questions: cfg.questions.map((q) => q.id),
|
|
810
858
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
811
859
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
@@ -855,6 +903,10 @@ function assertKey(_value) {
|
|
|
855
903
|
// key.json is this script's own output; what an older build left out is checked where it is read.
|
|
856
904
|
}
|
|
857
905
|
const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
|
|
906
|
+
/** A page built with `min_seconds` also stores how long the item was on screen before its first save. */
|
|
907
|
+
const answerDocFields = (key) => key.min_seconds === undefined
|
|
908
|
+
? ANSWER_DOC_FIELDS
|
|
909
|
+
: ["answers", "reasons", "savedAt", "seconds", "source"];
|
|
858
910
|
const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
|
|
859
911
|
const DOC_FIELD_TYPES = [
|
|
860
912
|
["answers", isObject, "an object {question: value}"],
|
|
@@ -869,6 +921,11 @@ const DOC_FIELD_TYPES = [
|
|
|
869
921
|
(v) => v === null || typeof v === "string",
|
|
870
922
|
"a timestamp string or null",
|
|
871
923
|
],
|
|
924
|
+
[
|
|
925
|
+
"seconds",
|
|
926
|
+
(v) => v === null || (typeof v === "number" && Number.isInteger(v) && v >= 0),
|
|
927
|
+
"a whole number of seconds or null",
|
|
928
|
+
],
|
|
872
929
|
];
|
|
873
930
|
const COLLECTIONS = ["answers", "groups", "practice"];
|
|
874
931
|
const COLLECTION_NAMES = COLLECTIONS;
|
|
@@ -888,15 +945,18 @@ const practiceDocs = (key) => {
|
|
|
888
945
|
// Each round's own questions: a later round may have dropped or renamed one.
|
|
889
946
|
return Object.entries(roundsIn(p)).flatMap(([r, expected]) => Object.entries(expected).map(([pid, answers]) => [`r${r}-${pid}`, Object.keys(answers)]));
|
|
890
947
|
};
|
|
891
|
-
|
|
892
|
-
|
|
948
|
+
/** Who an item is shown to: its own list, else every answerer of the page. */
|
|
949
|
+
const shownTo = (all, meta) => meta.answerers ?? all;
|
|
950
|
+
/** The names of a page without answerers, or those of one answerer's documents. */
|
|
951
|
+
function knownNames(key, whose) {
|
|
952
|
+
const entries = Object.entries(key.ids).filter(([, m]) => whose.who === undefined || shownTo(whose.all, m).includes(whose.who));
|
|
893
953
|
const shown = entries.filter(([, m]) => m.on_page);
|
|
894
954
|
const practice = practiceDocs(key);
|
|
895
955
|
return {
|
|
896
956
|
answers: {
|
|
897
957
|
known: entries.map(([nid]) => [nid, key.questions]),
|
|
898
958
|
onPage: shown.map(([nid]) => nid),
|
|
899
|
-
fields:
|
|
959
|
+
fields: answerDocFields(key),
|
|
900
960
|
},
|
|
901
961
|
groups: {
|
|
902
962
|
known: entries.map(([, m]) => [m.group, key.questions]),
|
|
@@ -906,7 +966,7 @@ function knownNames(key) {
|
|
|
906
966
|
practice: {
|
|
907
967
|
known: practice,
|
|
908
968
|
onPage: practice.map(([n]) => n),
|
|
909
|
-
fields:
|
|
969
|
+
fields: answerDocFields(key),
|
|
910
970
|
},
|
|
911
971
|
};
|
|
912
972
|
}
|
|
@@ -925,10 +985,28 @@ function checkDumpShape(dump) {
|
|
|
925
985
|
if (stray !== undefined)
|
|
926
986
|
fail(`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`);
|
|
927
987
|
}
|
|
928
|
-
|
|
988
|
+
/**
|
|
989
|
+
* A document's answerer and its name without it. A page without answerers has none; with them, an
|
|
990
|
+
* id without `~`, or before it a name the page does not have, is refused.
|
|
991
|
+
*/
|
|
992
|
+
function answererOf(where, docId, key) {
|
|
993
|
+
const all = key.answerers;
|
|
994
|
+
if (all === undefined)
|
|
995
|
+
return { name: docId };
|
|
996
|
+
const at = docId.indexOf(SEP);
|
|
997
|
+
if (at === -1)
|
|
998
|
+
return fail(`${where}: this page has answerers (${all.join(", ")}), so a document's id is <answerer>${SEP}<id>`);
|
|
999
|
+
const who = docId.slice(0, at);
|
|
1000
|
+
if (!all.includes(who))
|
|
1001
|
+
fail(`${where}: ${repr(who)} is not an answerer of this page (${all.join(", ")})`);
|
|
1002
|
+
return { who, all, name: docId.slice(at + 1) };
|
|
1003
|
+
}
|
|
1004
|
+
function checkDocName(where, { who, name }, kind, names) {
|
|
929
1005
|
const hit = names.known.find(([n]) => n === name);
|
|
930
1006
|
if (hit === undefined) {
|
|
931
|
-
return fail(`${where}: the key does not know this ${NOUN[kind]}
|
|
1007
|
+
return fail(`${where}: the key does not know this ${NOUN[kind]}${who === undefined ? "" : ` for ${who}`} (${R.unique(names.known.map(([n]) => n))
|
|
1008
|
+
.sort()
|
|
1009
|
+
.join(", ")})`);
|
|
932
1010
|
}
|
|
933
1011
|
if (!names.onPage.includes(name)) {
|
|
934
1012
|
fail(`${where}: stored, but no longer on the page (a rebuild left it out), so the export would drop it; rebuild with it on the page, or take it out of the dump on purpose`);
|
|
@@ -983,13 +1061,16 @@ function checkDocAnswers(where, doc, questions) {
|
|
|
983
1061
|
*/
|
|
984
1062
|
function checkDump(dump, key) {
|
|
985
1063
|
checkDumpShape(dump);
|
|
986
|
-
|
|
1064
|
+
// A page without answerers knows one set of names; each answerer has their own.
|
|
1065
|
+
const plain = knownNames(key, {});
|
|
987
1066
|
for (const kind of COLLECTIONS) {
|
|
988
1067
|
const collection = dump[kind];
|
|
989
|
-
for (const [
|
|
990
|
-
const where = `${kind}/${
|
|
991
|
-
const
|
|
992
|
-
|
|
1068
|
+
for (const [docId, doc] of sortedEntries(isObject(collection) ? collection : {})) {
|
|
1069
|
+
const where = `${kind}/${docId}`;
|
|
1070
|
+
const named = answererOf(where, docId, key);
|
|
1071
|
+
const names = (named.who === undefined ? plain : knownNames(key, named))[kind];
|
|
1072
|
+
const questions = checkDocName(where, named, kind, names);
|
|
1073
|
+
checkDocFields(where, doc, kind, names.fields);
|
|
993
1074
|
checkDocAnswers(where, doc, questions);
|
|
994
1075
|
}
|
|
995
1076
|
}
|
|
@@ -1042,8 +1123,8 @@ function answersOf(doc, ctx) {
|
|
|
1042
1123
|
const given = Object.fromEntries(raw.map(([q, v]) => [q, has(invalid, q) ? null : v]));
|
|
1043
1124
|
return { given, invalid };
|
|
1044
1125
|
}
|
|
1045
|
-
/** One
|
|
1046
|
-
function
|
|
1126
|
+
/** One stored answer: its answers, what is invalid, the reasons, and whether it is done. */
|
|
1127
|
+
function answerOf(doc, ctx) {
|
|
1047
1128
|
const { given, invalid } = answersOf(doc, ctx);
|
|
1048
1129
|
const reasons = Object.fromEntries(Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()));
|
|
1049
1130
|
const needsWhy = ctx.allowed.needs_why ?? {};
|
|
@@ -1051,9 +1132,6 @@ function labelOf(nid, meta, doc, ctx) {
|
|
|
1051
1132
|
.filter(([q, v]) => v !== null && listed(needsWhy, q, v) && !has(reasons, q))
|
|
1052
1133
|
.map(([q]) => q);
|
|
1053
1134
|
return {
|
|
1054
|
-
id: nid,
|
|
1055
|
-
source_id: meta.source_id,
|
|
1056
|
-
group: meta.group,
|
|
1057
1135
|
answers: given,
|
|
1058
1136
|
invalid_answers: invalid,
|
|
1059
1137
|
reasons,
|
|
@@ -1061,12 +1139,23 @@ function labelOf(nid, meta, doc, ctx) {
|
|
|
1061
1139
|
complete: Object.values(given).every((v) => v !== null) && !unexplained.length,
|
|
1062
1140
|
source: textOf(doc["source"]),
|
|
1063
1141
|
saved_at: doc["savedAt"] ?? null,
|
|
1142
|
+
// Only a page built with `min_seconds` stores how long the item was on screen.
|
|
1143
|
+
...R.pick({ seconds: doc["seconds"] ?? null }, secondsOf(ctx.key)),
|
|
1064
1144
|
};
|
|
1065
1145
|
}
|
|
1146
|
+
/** The `seconds` field, on a page built with `min_seconds`; none on any other. */
|
|
1147
|
+
const secondsOf = (key) => key.min_seconds === undefined ? [] : ["seconds"];
|
|
1148
|
+
/** One item of the labels file of a page without answerers. */
|
|
1149
|
+
const labelOf = (nid, meta, doc, ctx) => ({
|
|
1150
|
+
id: nid,
|
|
1151
|
+
source_id: meta.source_id,
|
|
1152
|
+
group: meta.group,
|
|
1153
|
+
...answerOf(doc, ctx),
|
|
1154
|
+
});
|
|
1066
1155
|
/** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
|
|
1067
|
-
function groupsOf(groupIds, groups, allowed) {
|
|
1156
|
+
function groupsOf(groupIds, groups, allowed, prefix = "") {
|
|
1068
1157
|
return Object.fromEntries(groupIds.map((g) => {
|
|
1069
|
-
const doc = docOf(groups, g);
|
|
1158
|
+
const doc = docOf(groups, `${prefix}${g}`);
|
|
1070
1159
|
const fam = textOf(doc["familiarity"]);
|
|
1071
1160
|
const bad = fam !== null && !allowed.familiarity.includes(fam);
|
|
1072
1161
|
return [
|
|
@@ -1104,9 +1193,9 @@ function reportGroups(groups, allowed) {
|
|
|
1104
1193
|
}
|
|
1105
1194
|
}
|
|
1106
1195
|
/** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
|
|
1107
|
-
function scorePractice(dump, round, all) {
|
|
1196
|
+
function scorePractice(dump, round, all, prefix) {
|
|
1108
1197
|
return Object.entries(all).map(([pid, expected]) => {
|
|
1109
|
-
const doc = docOf(dump["practice"],
|
|
1198
|
+
const doc = docOf(dump["practice"], `${prefix}r${round}-${pid}`);
|
|
1110
1199
|
const answers = isObject(doc["answers"]) ? doc["answers"] : {};
|
|
1111
1200
|
const matched = Object.entries(expected).every(([q, v]) => answers[q] === v);
|
|
1112
1201
|
return {
|
|
@@ -1118,8 +1207,8 @@ function scorePractice(dump, round, all) {
|
|
|
1118
1207
|
};
|
|
1119
1208
|
});
|
|
1120
1209
|
}
|
|
1121
|
-
/** One file per round: a second round exported the same day keeps the first. */
|
|
1122
|
-
const practiceFile = (study, round, day) => `${study}-practice-r${String(round)}-${day}.json`;
|
|
1210
|
+
/** One file per round (and answerer): a second round exported the same day keeps the first. */
|
|
1211
|
+
const practiceFile = (study, round, day, who) => `${study}-practice-r${String(round)}${who === undefined ? "" : `-${who}`}-${day}.json`;
|
|
1123
1212
|
/**
|
|
1124
1213
|
* Every practice round the store holds answers for, each scored against its own expected answers
|
|
1125
1214
|
* into its own file; never into the labels. The key's own round is reported even unanswered.
|
|
@@ -1128,56 +1217,99 @@ function exportPractice(dump, key, target, day) {
|
|
|
1128
1217
|
const p = key.practice;
|
|
1129
1218
|
if (p === undefined)
|
|
1130
1219
|
return;
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
.forEach(([round, expected]) => {
|
|
1134
|
-
|
|
1135
|
-
target,
|
|
1136
|
-
day,
|
|
1137
|
-
current: Number(round) === p.round,
|
|
1220
|
+
const rounds = R.sortBy(R.entries(roundsIn(p)), ([r]) => Number(r));
|
|
1221
|
+
(key.answerers ?? [undefined]).forEach((who) => {
|
|
1222
|
+
rounds.forEach(([round, expected]) => {
|
|
1223
|
+
exportRound(key.study, Number(round), scorePractice(dump, round, expected, who === undefined ? "" : `${who}${SEP}`), { target, day, current: Number(round) === p.round, who });
|
|
1138
1224
|
});
|
|
1139
1225
|
});
|
|
1140
1226
|
}
|
|
1141
1227
|
/** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
|
|
1142
1228
|
function exportRound(study, round, items, at) {
|
|
1229
|
+
const named = `practice round ${String(round)}${at.who === undefined ? "" : ` (${at.who})`}`;
|
|
1143
1230
|
if (!items.some((i) => Object.keys(i.answers).length)) {
|
|
1144
1231
|
if (at.current)
|
|
1145
|
-
say(
|
|
1232
|
+
say(`${named}: not answered`);
|
|
1146
1233
|
return;
|
|
1147
1234
|
}
|
|
1148
1235
|
const missed = items.filter((i) => !i.matched).map((i) => i.id);
|
|
1149
1236
|
const matched = items.length - missed.length;
|
|
1150
|
-
const path = join(at.target, practiceFile(study, round, at.day));
|
|
1237
|
+
const path = join(at.target, practiceFile(study, round, at.day, at.who));
|
|
1151
1238
|
writeJson(path, {
|
|
1152
1239
|
study,
|
|
1153
1240
|
round,
|
|
1241
|
+
...R.pickBy({ answerer: at.who }, R.isDefined),
|
|
1154
1242
|
exported: at.day,
|
|
1155
1243
|
matched,
|
|
1156
1244
|
total: items.length,
|
|
1157
1245
|
items,
|
|
1158
1246
|
});
|
|
1159
|
-
say(
|
|
1247
|
+
say(`${named}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`);
|
|
1160
1248
|
}
|
|
1249
|
+
/** The counts of one set of answers and groups. */
|
|
1250
|
+
const tally = (items, groups) => ({
|
|
1251
|
+
groups,
|
|
1252
|
+
complete_items: items.filter((i) => i.complete).length,
|
|
1253
|
+
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1254
|
+
.length,
|
|
1255
|
+
invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
|
|
1256
|
+
total_items: items.length,
|
|
1257
|
+
});
|
|
1258
|
+
/** The familiarity of the groups of `onPage`, or none on a page that asks none. */
|
|
1259
|
+
const groupsFor = (onPage, dump, ctx, prefix = "") => ctx.allowed.familiarity.length
|
|
1260
|
+
? groupsOf(R.unique(onPage.map(([, m]) => m.group)), dump["groups"], ctx.allowed, prefix)
|
|
1261
|
+
: {};
|
|
1161
1262
|
/** The labels file: every item on the page with its answers, the groups, and the counts. */
|
|
1162
1263
|
function labelsOf(dump, ctx, day) {
|
|
1163
1264
|
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1164
1265
|
const items = onPage.map(([nid, meta]) => labelOf(nid, meta, docOf(dump["answers"], nid), ctx));
|
|
1165
|
-
const groups = ctx.allowed.familiarity.length
|
|
1166
|
-
? groupsOf([...new Set(onPage.map(([, m]) => m.group))], dump["groups"], ctx.allowed)
|
|
1167
|
-
: {};
|
|
1168
1266
|
return {
|
|
1169
1267
|
study: ctx.key.study,
|
|
1170
1268
|
exported: day,
|
|
1171
1269
|
questions: ctx.key.questions,
|
|
1172
1270
|
items,
|
|
1173
|
-
|
|
1174
|
-
complete_items: items.filter((i) => i.complete).length,
|
|
1175
|
-
invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
|
|
1176
|
-
.length,
|
|
1177
|
-
invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
|
|
1178
|
-
total_items: items.length,
|
|
1271
|
+
...tally(items, groupsFor(onPage, dump, ctx)),
|
|
1179
1272
|
};
|
|
1180
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* The labels file of a page with answerers: each item with every answerer it is shown to and their
|
|
1276
|
+
* answers apart (`by_answerer`), and each answerer's groups and counts (`by_answerer` at the top).
|
|
1277
|
+
*/
|
|
1278
|
+
function answererLabelsOf(dump, ctx, day, all) {
|
|
1279
|
+
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
1280
|
+
const docFor = (who, nid) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx);
|
|
1281
|
+
const items = onPage.map(([nid, meta]) => ({
|
|
1282
|
+
id: nid,
|
|
1283
|
+
source_id: meta.source_id,
|
|
1284
|
+
group: meta.group,
|
|
1285
|
+
answerers: shownTo(all, meta),
|
|
1286
|
+
by_answerer: R.fromKeys(shownTo(all, meta), (who) => docFor(who, nid)),
|
|
1287
|
+
}));
|
|
1288
|
+
return {
|
|
1289
|
+
study: ctx.key.study,
|
|
1290
|
+
exported: day,
|
|
1291
|
+
questions: ctx.key.questions,
|
|
1292
|
+
answerers: all,
|
|
1293
|
+
items,
|
|
1294
|
+
by_answerer: R.fromKeys(all, (who) => tallyOf({ who, all }, onPage, dump, ctx)),
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
/** One answerer's groups and counts, over the items shown to them. */
|
|
1298
|
+
function tallyOf({ who, all }, onPage, dump, ctx) {
|
|
1299
|
+
const mine = onPage.filter(([, m]) => shownTo(all, m).includes(who));
|
|
1300
|
+
return tally(mine.map(([nid]) => answerOf(docOf(dump["answers"], `${who}${SEP}${nid}`), ctx)), groupsFor(mine, dump, ctx, `${who}${SEP}`));
|
|
1301
|
+
}
|
|
1302
|
+
/** Each answerer's items and groups, named `<answerer>~<id>` in the report, as in the store. */
|
|
1303
|
+
function reportAnswerers(out, allowed) {
|
|
1304
|
+
R.forEachObj(out.by_answerer, (t, who) => {
|
|
1305
|
+
reportLabels(out.items.flatMap((i) => {
|
|
1306
|
+
const a = i.by_answerer[who];
|
|
1307
|
+
return a === undefined ? [] : [{ id: `${who}${SEP}${i.id}`, ...a }];
|
|
1308
|
+
}), allowed);
|
|
1309
|
+
reportGroups(R.mapKeys(t.groups, (g) => `${who}${SEP}${g}`), allowed);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
const completeLine = (t) => `${String(t.complete_items)} of ${String(t.total_items)} items complete`;
|
|
1181
1313
|
export function exportLabels(dumpPath, keyPath, outDir, day) {
|
|
1182
1314
|
const dump = readJson(dumpPath);
|
|
1183
1315
|
const key = readJson(keyPath);
|
|
@@ -1186,12 +1318,28 @@ export function exportLabels(dumpPath, keyPath, outDir, day) {
|
|
|
1186
1318
|
assertAllowed(allowed, key, keyPath);
|
|
1187
1319
|
checkDump(dump, key);
|
|
1188
1320
|
checkNoFamiliarity(dump, allowed);
|
|
1189
|
-
const
|
|
1321
|
+
const ctx = { key, allowed };
|
|
1322
|
+
const all = key.answerers;
|
|
1323
|
+
const out = all === undefined
|
|
1324
|
+
? labelsOf(dump, ctx, day)
|
|
1325
|
+
: answererLabelsOf(dump, ctx, day, all);
|
|
1190
1326
|
mkdirSync(outDir, { recursive: true });
|
|
1191
1327
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|
|
1192
1328
|
writeJson(path, out);
|
|
1193
1329
|
exportPractice(dump, key, outDir, day);
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1330
|
+
reportExport(path, out, allowed);
|
|
1331
|
+
}
|
|
1332
|
+
/** What the export wrote, and every item and group it did not count. */
|
|
1333
|
+
function reportExport(path, out, allowed) {
|
|
1334
|
+
if ("by_answerer" in out) {
|
|
1335
|
+
say(`wrote ${path}: ${R.entries(out.by_answerer)
|
|
1336
|
+
.map(([who, t]) => `${who}: ${completeLine(t)}`)
|
|
1337
|
+
.join("; ")}`);
|
|
1338
|
+
reportAnswerers(out, allowed);
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
say(`wrote ${path}: ${completeLine(out)}`);
|
|
1342
|
+
reportLabels(out.items, allowed);
|
|
1343
|
+
reportGroups(out.groups, allowed);
|
|
1344
|
+
}
|
|
1197
1345
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import * as R from "remeda";
|
|
9
10
|
import { test } from "vitest";
|
|
10
11
|
import { z } from "zod";
|
|
11
12
|
import {
|
|
@@ -133,6 +134,8 @@ const FULL: DocObject = (() => {
|
|
|
133
134
|
QUESTIONS[1],
|
|
134
135
|
],
|
|
135
136
|
labeller_knows: ["python", "go", "rust"],
|
|
137
|
+
answerers: ["author", "second"],
|
|
138
|
+
min_seconds: 30,
|
|
136
139
|
familiarity: true,
|
|
137
140
|
familiarity_question: "How well do you know {group}?",
|
|
138
141
|
labels: {
|
|
@@ -144,7 +147,9 @@ const FULL: DocObject = (() => {
|
|
|
144
147
|
practice_example: "P",
|
|
145
148
|
},
|
|
146
149
|
practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
|
|
147
|
-
items: ITEMS.map((i, n) =>
|
|
150
|
+
items: ITEMS.map((i, n) =>
|
|
151
|
+
n === 0 ? { ...i, ...CARD, answerers: ["author"] } : i,
|
|
152
|
+
),
|
|
148
153
|
}),
|
|
149
154
|
);
|
|
150
155
|
assert.ok(isObject(doc));
|
|
@@ -275,6 +280,21 @@ function badCases({ node, member }: Leaf): readonly BadCase[] {
|
|
|
275
280
|
return [...wrongType, ...extra];
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
/** `doc` with its first item's own answerers taken out. */
|
|
284
|
+
function withoutItemAnswerers(doc: DocObject): DocObject {
|
|
285
|
+
const out = put(doc, ["items", 0, "answerers"], undefined);
|
|
286
|
+
assert.ok(isObject(out));
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* A field whose absence takes another with it: familiarity_question asks nothing unless familiarity
|
|
291
|
+
* is on, and an item's answerers name the page's.
|
|
292
|
+
*/
|
|
293
|
+
const RELAX: Partial<Record<string, (doc: DocObject) => DocObject>> = {
|
|
294
|
+
familiarity: (doc) => R.omit(doc, ["familiarity_question"]),
|
|
295
|
+
answerers: withoutItemAnswerers,
|
|
296
|
+
};
|
|
297
|
+
|
|
278
298
|
/** Spoil `leaf` each way in `badCases`; every one must stop the build and name the path. */
|
|
279
299
|
function expectRefused(leaf: Leaf): void {
|
|
280
300
|
badCases(leaf).forEach(([label, value], i) => {
|
|
@@ -290,16 +310,9 @@ function expectRefused(leaf: Leaf): void {
|
|
|
290
310
|
});
|
|
291
311
|
if (!leaf.node.required && !leaf.member) {
|
|
292
312
|
const absent = spoil(leaf.path, undefined);
|
|
293
|
-
// familiarity_question asks nothing unless familiarity is on: the two leave together.
|
|
294
313
|
const r = tryBuild(
|
|
295
314
|
`schema-${leaf.path.replace(/\W/g, "_")}-absent`,
|
|
296
|
-
leaf.path
|
|
297
|
-
? Object.fromEntries(
|
|
298
|
-
Object.entries(absent).filter(
|
|
299
|
-
([k]) => k !== "familiarity_question",
|
|
300
|
-
),
|
|
301
|
-
)
|
|
302
|
-
: absent,
|
|
315
|
+
(RELAX[leaf.path] ?? R.identity())(absent),
|
|
303
316
|
);
|
|
304
317
|
assert.equal(r.status, 0, `${leaf.path} is optional: ${r.out}`);
|
|
305
318
|
}
|
|
@@ -313,6 +326,8 @@ const DOCUMENTED_FIELDS = [
|
|
|
313
326
|
"seed",
|
|
314
327
|
"groups",
|
|
315
328
|
"labeller_knows",
|
|
329
|
+
"answerers",
|
|
330
|
+
"min_seconds",
|
|
316
331
|
"familiarity",
|
|
317
332
|
"familiarity_question",
|
|
318
333
|
"questions",
|