paperlint 13.3.2 → 13.5.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 +16 -1
- package/package.json +1 -1
- package/skills/assess-novelty/scripts/candidate_page.test.ts +1 -32
- package/skills/build-label-page/SKILL.md +3 -2
- package/skills/build-label-page/SKILL.md.spec.ts +2 -1
- package/skills/build-label-page/references/checklist.md +4 -2
- package/skills/build-label-page/references/closed-choice-generator.md +21 -4
- package/skills/build-label-page/scripts/label-page-browser.test.ts +187 -0
- package/skills/build-label-page/scripts/label-page-core.js +108 -16
- package/skills/build-label-page/scripts/label-page-core.test.ts +206 -3
- package/skills/build-label-page/scripts/label-page-core.ts +167 -19
- package/skills/build-label-page/scripts/label-page-html.test.ts +60 -0
- package/skills/build-label-page/scripts/label-page.js +6 -1
- package/skills/build-label-page/scripts/label-page.ts +6 -1
- package/skills/build-label-page/scripts/page-template.html +75 -11
|
@@ -12,6 +12,7 @@ type Json = null | boolean | number | string | readonly Json[] | {
|
|
|
12
12
|
type JsonObject = {
|
|
13
13
|
readonly [key: string]: Json;
|
|
14
14
|
};
|
|
15
|
+
declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context"];
|
|
15
16
|
type SchemaNode = {
|
|
16
17
|
kind: "string";
|
|
17
18
|
required: boolean;
|
|
@@ -19,6 +20,9 @@ type SchemaNode = {
|
|
|
19
20
|
} | {
|
|
20
21
|
kind: "integer";
|
|
21
22
|
required: boolean;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "boolean";
|
|
25
|
+
required: boolean;
|
|
22
26
|
} | {
|
|
23
27
|
kind: "array";
|
|
24
28
|
required: boolean;
|
|
@@ -50,9 +54,19 @@ interface Question {
|
|
|
50
54
|
readonly short?: string;
|
|
51
55
|
readonly options: readonly Option[];
|
|
52
56
|
}
|
|
53
|
-
|
|
57
|
+
interface Unit {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly text: string;
|
|
60
|
+
readonly part: number;
|
|
61
|
+
readonly of: number;
|
|
62
|
+
}
|
|
63
|
+
type Item = {
|
|
64
|
+
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
65
|
+
} & {
|
|
54
66
|
readonly source_id: string;
|
|
55
67
|
readonly group: string;
|
|
68
|
+
readonly body: string;
|
|
69
|
+
readonly unit?: Unit;
|
|
56
70
|
};
|
|
57
71
|
interface Practice {
|
|
58
72
|
readonly title?: string;
|
|
@@ -69,6 +83,7 @@ interface Config {
|
|
|
69
83
|
readonly seed: number;
|
|
70
84
|
readonly groups: Readonly<Record<string, string>>;
|
|
71
85
|
readonly labeller_knows?: readonly string[];
|
|
86
|
+
readonly familiarity?: boolean;
|
|
72
87
|
readonly familiarity_question?: string;
|
|
73
88
|
readonly questions: readonly Question[];
|
|
74
89
|
readonly practice: readonly Practice[];
|
package/package.json
CHANGED
|
@@ -26,42 +26,11 @@ import assert from "node:assert/strict";
|
|
|
26
26
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
27
|
const TOOL = join(here, "candidate_page.ts");
|
|
28
28
|
const FIXTURE = join(here, "fixtures", "novelty");
|
|
29
|
-
// playwright-core is what package.json declares; the browser is whichever chromiumPath() finds (CHROMIUM_PATH, a
|
|
30
|
-
// build in PLAYWRIGHT_BROWSERS_PATH, or the Chrome a GitHub runner ships), so nothing is downloaded.
|
|
31
29
|
import { chromium } from "playwright-core";
|
|
32
|
-
import {
|
|
30
|
+
import { chromiumPath } from "../../../test/chromium.ts";
|
|
33
31
|
import { test } from "vitest";
|
|
34
32
|
|
|
35
33
|
test("candidate_page", async () => {
|
|
36
|
-
const BROWSERS_DIR =
|
|
37
|
-
process.env.PLAYWRIGHT_BROWSERS_PATH || "/opt/pw-browsers";
|
|
38
|
-
const SYSTEM_CHROME = [
|
|
39
|
-
"/usr/bin/google-chrome",
|
|
40
|
-
"/usr/bin/google-chrome-stable",
|
|
41
|
-
"/usr/bin/chromium",
|
|
42
|
-
"/usr/bin/chromium-browser",
|
|
43
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", // the macOS runner's Chrome
|
|
44
|
-
];
|
|
45
|
-
const chromiumPath = (): string => {
|
|
46
|
-
const builds = existsSync(BROWSERS_DIR)
|
|
47
|
-
? readdirSync(BROWSERS_DIR)
|
|
48
|
-
.filter((d) => /^chromium-\d+$/.test(d))
|
|
49
|
-
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]))
|
|
50
|
-
.flatMap((d) =>
|
|
51
|
-
["chrome-linux64", "chrome-linux"].map((sub) =>
|
|
52
|
-
join(BROWSERS_DIR, d, sub, "chrome"),
|
|
53
|
-
),
|
|
54
|
-
)
|
|
55
|
-
: [];
|
|
56
|
-
const found = [process.env.CHROMIUM_PATH, ...builds, ...SYSTEM_CHROME].find(
|
|
57
|
-
(p) => p && existsSync(p),
|
|
58
|
-
);
|
|
59
|
-
if (!found)
|
|
60
|
-
throw new Error(
|
|
61
|
-
`no Chromium: set CHROMIUM_PATH, or install one in ${BROWSERS_DIR} or ${SYSTEM_CHROME[0]}`,
|
|
62
|
-
);
|
|
63
|
-
return found;
|
|
64
|
-
};
|
|
65
34
|
// A 1x1 PNG, enough for an <img> to load.
|
|
66
35
|
const PNG = Buffer.from(
|
|
67
36
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
@@ -4,7 +4,7 @@ description: Build a phone labelling page published as a claude.ai Artifact with
|
|
|
4
4
|
allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Artifact, ArtifactData]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
<!-- vigiles:sha256:
|
|
7
|
+
<!-- vigiles:sha256:bd616b2694831fa1 compiled from skills/build-label-page/SKILL.md.spec.ts -->
|
|
8
8
|
|
|
9
9
|
# build-label-page — a page the person labels on a phone, and the labels back in the repo
|
|
10
10
|
|
|
@@ -63,7 +63,8 @@ building. In one line each:
|
|
|
63
63
|
source labels on the page. The generator refuses to build when an item carries a field outside an
|
|
64
64
|
allow-list; a page written by hand checks its items against the same list.
|
|
65
65
|
5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
|
|
66
|
-
you know it" question per group, used later as an analysis filter
|
|
66
|
+
you know it" question per group, used later as an analysis filter (off on an approval page,
|
|
67
|
+
`"familiarity": false`: the author has not seen those repositories); "clear-cut or real boundary"
|
|
67
68
|
is the labeller's judgement, not the designer's.
|
|
68
69
|
6. **Export into the repository right away**, one JSON per page into the study's `data/` with the
|
|
69
70
|
export date, commit it, and record answers given in chat with `source: "chat"`. If the page fails,
|
|
@@ -74,7 +74,8 @@ building. In one line each:
|
|
|
74
74
|
source labels on the page. The generator refuses to build when an item carries a field outside an
|
|
75
75
|
allow-list; a page written by hand checks its items against the same list.
|
|
76
76
|
5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
|
|
77
|
-
you know it" question per group, used later as an analysis filter
|
|
77
|
+
you know it" question per group, used later as an analysis filter (off on an approval page,
|
|
78
|
+
\`"familiarity": false\`: the author has not seen those repositories); "clear-cut or real boundary"
|
|
78
79
|
is the labeller's judgement, not the designer's.
|
|
79
80
|
6. **Export into the repository right away**, one JSON per page into the study's \`data/\` with the
|
|
80
81
|
export date, commit it, and record answers given in chat with \`source: "chat"\`. If the page fails,
|
|
@@ -83,7 +83,9 @@ the text of an allowed field says.
|
|
|
83
83
|
other groups are not put on the page and get no id.
|
|
84
84
|
- Add one question per group, "how well do you know this language / tool" (well / somewhat / not
|
|
85
85
|
really). It is an analysis filter: the study can report the labels with and without the groups
|
|
86
|
-
the labeller does not know well.
|
|
86
|
+
the labeller does not know well. An approval page asks none (`"familiarity": false` in
|
|
87
|
+
`items.json`): its question is about a drawn unit, not about repositories the author has seen,
|
|
88
|
+
and an answer to "how well do you know the projects of this group" there means nothing.
|
|
87
89
|
- The per-item judgement "clear-cut or a real boundary case" is the labeller's. The study design
|
|
88
90
|
does not decide it in advance and does not pre-fill it.
|
|
89
91
|
|
|
@@ -92,7 +94,7 @@ answer is the only way to find it afterwards. And a boundary case is boundary be
|
|
|
92
94
|
person hesitates; a designer who tags it in advance has measured the design, not the item.
|
|
93
95
|
|
|
94
96
|
**Check.** The build prints how many items were left out; the export has a familiarity value for
|
|
95
|
-
every group.
|
|
97
|
+
every group, or the page was built with `"familiarity": false` and the export has no groups.
|
|
96
98
|
|
|
97
99
|
## 6. Export into the repository right away
|
|
98
100
|
|
|
@@ -37,12 +37,22 @@ length.
|
|
|
37
37
|
- **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
|
|
38
38
|
the earlier round are kept but not counted.
|
|
39
39
|
- **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
|
|
40
|
-
`code`, `context`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
40
|
+
`code`, `context`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
41
41
|
is taken out of the item, not added to the list to make a build pass.
|
|
42
|
+
- **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
|
|
43
|
+
("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
|
|
44
|
+
own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
|
|
45
|
+
whole line beneath, so the labeller judges one requirement at a time and sees where it came from.
|
|
46
|
+
The build refuses a line whose parts do not add up (one missing, repeated, a different `of`, or
|
|
47
|
+
split across groups), naming the item. `id` names one occurrence of the line (a file and line
|
|
48
|
+
number, say) and is never shown: two files holding the same words are two lines, each judged in
|
|
49
|
+
full. Splitting the line is the study's job, before the page.
|
|
42
50
|
- **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
|
|
43
51
|
next one; a different seed is refused once a key exists.
|
|
44
52
|
- **`ask_why`** on an option: a prompt. That answer is incomplete, on the page and in the export,
|
|
45
|
-
until a reason is saved.
|
|
53
|
+
until a reason is saved. The reason saves as it is typed (after a pause, and when the page is
|
|
54
|
+
left), so a labeller who closes the page from inside the box does not leave a drop without its
|
|
55
|
+
reason; a store echo while typing keeps the box and its focus.
|
|
46
56
|
|
|
47
57
|
## The approval page
|
|
48
58
|
|
|
@@ -73,8 +83,15 @@ drop, the drop carrying `ask_why`.
|
|
|
73
83
|
}
|
|
74
84
|
```
|
|
75
85
|
|
|
76
|
-
The
|
|
77
|
-
|
|
86
|
+
The page sets `"familiarity": false`: the author is judging drawn units, not repositories they
|
|
87
|
+
have seen, so the per-group "how well do you know it" question is left out (and a
|
|
88
|
+
`familiarity_question` beside it is refused).
|
|
89
|
+
|
|
90
|
+
The items are the drawn units verbatim, in a language the author reads. Units in other languages
|
|
91
|
+
are taken out before the draw, as a population rule the study's `plan.json` states ("English-language
|
|
92
|
+
units only") with the number excluded reported in the paper; a unit dropped at approval because the
|
|
93
|
+
author cannot read it is an exclusion made after the draw and goes unrecorded. The practice is three
|
|
94
|
+
units whose fate is obvious. Each dropped unit carries its reason in the labels file. The author's words, with the date, then go into
|
|
78
95
|
`author_check` of the plan; a dropped unit is replaced by the next one in the draw.
|
|
79
96
|
|
|
80
97
|
## The export
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generator's page in a real Chromium, against a stand-in store whose writes come back as a
|
|
3
|
+
* snapshot a moment later (as the claude.ai store does). What the fake DOM of label-page-html.test.ts
|
|
4
|
+
* cannot show: focus, typing, and a page that is left without the box ever losing focus.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { chromium, type Page } from "playwright-core";
|
|
8
|
+
import { test } from "vitest";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { chromiumPath } from "../../../test/chromium.ts";
|
|
11
|
+
import {
|
|
12
|
+
APPROVAL_PRACTICE,
|
|
13
|
+
approval,
|
|
14
|
+
build,
|
|
15
|
+
setup,
|
|
16
|
+
} from "../../../test/label-page-kit.ts";
|
|
17
|
+
|
|
18
|
+
/** A store that keeps every write and echoes it back as a snapshot after `echoMs`. */
|
|
19
|
+
const STORE = (practice: Record<string, unknown>, echoMs: number): string => `
|
|
20
|
+
window.__writes = [];
|
|
21
|
+
(function () {
|
|
22
|
+
var docs = { answers: {}, groups: {}, practice: ${JSON.stringify(practice)} };
|
|
23
|
+
var subs = {};
|
|
24
|
+
function snap(n) {
|
|
25
|
+
(subs[n] || []).forEach(function (cb) {
|
|
26
|
+
cb({ docs: Object.keys(docs[n]).map(function (id) { return { id: id, data: function () { return docs[n][id]; } }; }) });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
var db = { collection: function (n) { return {
|
|
30
|
+
onSnapshot: function (cb) { (subs[n] = subs[n] || []).push(cb); snap(n); },
|
|
31
|
+
doc: function (id) { return { set: function (body) {
|
|
32
|
+
window.__writes.push(body);
|
|
33
|
+
docs[n][id] = body;
|
|
34
|
+
return new Promise(function (r) { setTimeout(function () { r(); snap(n); }, ${String(echoMs)}); });
|
|
35
|
+
} }; }
|
|
36
|
+
}; } };
|
|
37
|
+
window.claude = { use: function () { return Promise.resolve(db); } };
|
|
38
|
+
})();`;
|
|
39
|
+
|
|
40
|
+
const PRACTICE_DONE = Object.fromEntries(
|
|
41
|
+
APPROVAL_PRACTICE.map((p, n) => [
|
|
42
|
+
`r1-P${String(n + 1)}`,
|
|
43
|
+
{ answers: p.expected, source: "page" },
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/** Every document the page wrote, in order, read back as JSON (the stand-in store keeps them). */
|
|
48
|
+
const Writes = z.array(
|
|
49
|
+
z.object({ reasons: z.record(z.string(), z.string()) }).loose(),
|
|
50
|
+
);
|
|
51
|
+
const writes = async (page: Page) =>
|
|
52
|
+
Writes.parse(
|
|
53
|
+
JSON.parse(await page.evaluate("JSON.stringify(window.__writes)")),
|
|
54
|
+
);
|
|
55
|
+
/** The reason the store was last given for the one question. */
|
|
56
|
+
const storedReason = async (page: Page): Promise<string | undefined> =>
|
|
57
|
+
(await writes(page)).at(-1)?.reasons["keep"];
|
|
58
|
+
|
|
59
|
+
/** Wait until the store's last write carries `text` as the reason (the page saves on its own). */
|
|
60
|
+
async function reasonStored(page: Page, text: string): Promise<void> {
|
|
61
|
+
await page.waitForFunction(
|
|
62
|
+
`(window.__writes || []).at(-1)?.reasons?.keep === ${JSON.stringify(text)}`,
|
|
63
|
+
undefined,
|
|
64
|
+
{ timeout: 10_000 },
|
|
65
|
+
);
|
|
66
|
+
assert.equal(await storedReason(page), text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A browser test launches Chromium and types: far above vitest's 5 s default on a slow runner. */
|
|
70
|
+
const BROWSER_TEST_MS = 60_000;
|
|
71
|
+
|
|
72
|
+
async function onApprovalPage(
|
|
73
|
+
name: string,
|
|
74
|
+
echoMs: number,
|
|
75
|
+
steps: (page: Page) => Promise<void>,
|
|
76
|
+
filter: "all" | "next" = "all",
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const { page: html } = build(setup(name, approval()));
|
|
79
|
+
const browser = await chromium.launch({
|
|
80
|
+
executablePath: chromiumPath(),
|
|
81
|
+
args: ["--no-sandbox"],
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const page = await browser.newPage();
|
|
85
|
+
await page.addInitScript(STORE(PRACTICE_DONE, echoMs));
|
|
86
|
+
await page.goto("about:blank");
|
|
87
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
88
|
+
if (filter === "next")
|
|
89
|
+
await page.getByRole("button", { name: "Next unanswered" }).click();
|
|
90
|
+
await page.getByRole("button", { name: /Drop/ }).first().click();
|
|
91
|
+
await steps(page);
|
|
92
|
+
} finally {
|
|
93
|
+
await browser.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test(
|
|
98
|
+
"🔴 a reason typed and never blurred is saved: after a pause in typing, and when the page is left",
|
|
99
|
+
async () => {
|
|
100
|
+
await onApprovalPage("browser-pause", 20, async (page) => {
|
|
101
|
+
await page.locator("textarea").first().click();
|
|
102
|
+
await page.keyboard.type("a process rule");
|
|
103
|
+
await reasonStored(page, "a process rule");
|
|
104
|
+
});
|
|
105
|
+
await onApprovalPage("browser-leave", 20, async (page) => {
|
|
106
|
+
await page.locator("textarea").first().click();
|
|
107
|
+
await page.keyboard.type("not a rule");
|
|
108
|
+
await page.evaluate(() => {
|
|
109
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
110
|
+
});
|
|
111
|
+
await reasonStored(page, "not a rule");
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
BROWSER_TEST_MS,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
test(
|
|
118
|
+
"🔴 the store's echo while typing keeps the box, its focus and every keystroke",
|
|
119
|
+
async () => {
|
|
120
|
+
await onApprovalPage("browser-echo", 300, async (page) => {
|
|
121
|
+
await page.locator("textarea").first().click();
|
|
122
|
+
await page.keyboard.type("a pro");
|
|
123
|
+
await page.waitForTimeout(450); // the Drop's own write comes back and the page renders again
|
|
124
|
+
await page.keyboard.type("cess rule");
|
|
125
|
+
assert.equal(
|
|
126
|
+
await page.evaluate(() => document.activeElement?.tagName),
|
|
127
|
+
"TEXTAREA",
|
|
128
|
+
);
|
|
129
|
+
assert.equal(
|
|
130
|
+
await page.locator("textarea").first().inputValue(),
|
|
131
|
+
"a process rule",
|
|
132
|
+
);
|
|
133
|
+
await reasonStored(page, "a process rule");
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
BROWSER_TEST_MS,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
test(
|
|
140
|
+
"🔴 under 'Next unanswered', the save that completes an item keeps the box being typed in on the page",
|
|
141
|
+
async () => {
|
|
142
|
+
await onApprovalPage(
|
|
143
|
+
"browser-next",
|
|
144
|
+
20,
|
|
145
|
+
async (page) => {
|
|
146
|
+
await page.locator("textarea").first().click();
|
|
147
|
+
await page.keyboard.type("a pro");
|
|
148
|
+
await reasonStored(page, "a pro"); // the item is complete now, and the filter hides complete items
|
|
149
|
+
await page.keyboard.type("cess rule");
|
|
150
|
+
assert.equal(
|
|
151
|
+
await page.evaluate(() => document.activeElement?.tagName),
|
|
152
|
+
"TEXTAREA",
|
|
153
|
+
);
|
|
154
|
+
await reasonStored(page, "a process rule");
|
|
155
|
+
},
|
|
156
|
+
"next",
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
BROWSER_TEST_MS,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
test(
|
|
163
|
+
"a Keep needs no reason and writes none: the page stays quiet",
|
|
164
|
+
async () => {
|
|
165
|
+
const { page: html } = build(setup("browser-keep", approval()));
|
|
166
|
+
const browser = await chromium.launch({
|
|
167
|
+
executablePath: chromiumPath(),
|
|
168
|
+
args: ["--no-sandbox"],
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
const page = await browser.newPage();
|
|
172
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
173
|
+
await page.goto("about:blank");
|
|
174
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
175
|
+
await page.getByRole("button", { name: /Keep/ }).first().click();
|
|
176
|
+
await page.waitForTimeout(1200); // longer than the reason's pause: nothing more may arrive
|
|
177
|
+
assert.equal(await page.locator("textarea").count(), 0);
|
|
178
|
+
assert.deepEqual(
|
|
179
|
+
(await writes(page)).map((w) => w.reasons),
|
|
180
|
+
[{}],
|
|
181
|
+
);
|
|
182
|
+
} finally {
|
|
183
|
+
await browser.close();
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
BROWSER_TEST_MS,
|
|
187
|
+
);
|
|
@@ -89,7 +89,14 @@ const readJson = (path) => {
|
|
|
89
89
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
90
90
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
|
|
91
91
|
const PRIVATE_FIELDS = ["source_id"];
|
|
92
|
-
|
|
92
|
+
// One requirement of a line that holds several: the whole line and which part this item is. Shown on
|
|
93
|
+
// the card beneath the item; the line is text the labeller may read, like `context`.
|
|
94
|
+
const UNIT_FIELD = "unit";
|
|
95
|
+
const ALLOWED_FIELDS = [
|
|
96
|
+
...PAGE_FIELDS,
|
|
97
|
+
UNIT_FIELD,
|
|
98
|
+
...PRIVATE_FIELDS,
|
|
99
|
+
];
|
|
93
100
|
const FAMILIARITY = [
|
|
94
101
|
{ value: "well", label: "Well" },
|
|
95
102
|
{ value: "somewhat", label: "Somewhat" },
|
|
@@ -123,6 +130,10 @@ const integer = (required = true) => ({
|
|
|
123
130
|
kind: "integer",
|
|
124
131
|
required,
|
|
125
132
|
});
|
|
133
|
+
const boolean = (required = true) => ({
|
|
134
|
+
kind: "boolean",
|
|
135
|
+
required,
|
|
136
|
+
});
|
|
126
137
|
const array = (items, required = true, min = 0) => ({
|
|
127
138
|
kind: "array",
|
|
128
139
|
required,
|
|
@@ -136,10 +147,13 @@ const mapping = (values, keyPattern, min = 0) => ({
|
|
|
136
147
|
key_pattern: keyPattern,
|
|
137
148
|
values,
|
|
138
149
|
});
|
|
139
|
-
const obj = (fields, closed = true) => ({ kind: "object", required
|
|
150
|
+
const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
|
|
140
151
|
const ITEM_SCHEMA = obj({
|
|
141
152
|
source_id: string(),
|
|
142
153
|
...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
|
|
154
|
+
[UNIT_FIELD]: obj(
|
|
155
|
+
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
156
|
+
{ id: string(), text: string(), part: integer(), of: integer() }, true, false),
|
|
143
157
|
}, false);
|
|
144
158
|
// `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
|
|
145
159
|
// reads as decoration, not meaning (the build-label-page look test refuses one).
|
|
@@ -173,6 +187,9 @@ export const CONFIG_SCHEMA = obj({
|
|
|
173
187
|
seed: integer(),
|
|
174
188
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
175
189
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
190
|
+
// false: no "how well do you know {group}" question (an approval page: the author has never seen
|
|
191
|
+
// these repositories, so its answer means nothing). Left out, every group is asked (rule 5).
|
|
192
|
+
familiarity: boolean(false),
|
|
176
193
|
familiarity_question: string(false),
|
|
177
194
|
questions: array(QUESTION_SCHEMA, true, 1),
|
|
178
195
|
practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
|
|
@@ -227,6 +244,10 @@ function checkObject(value, node, path, where) {
|
|
|
227
244
|
fail(`${at(path, unknown)}: not a known field (known: ${Object.keys(node.fields).join(", ")})`);
|
|
228
245
|
}
|
|
229
246
|
}
|
|
247
|
+
function checkBoolean(value, where) {
|
|
248
|
+
if (typeof value !== "boolean")
|
|
249
|
+
fail(`${where}: must be true or false, got ${repr(value)}`);
|
|
250
|
+
}
|
|
230
251
|
/** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
|
|
231
252
|
function checkValue(value, node, path) {
|
|
232
253
|
const where = path || "items.json";
|
|
@@ -239,6 +260,9 @@ function checkValue(value, node, path) {
|
|
|
239
260
|
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
240
261
|
}
|
|
241
262
|
break;
|
|
263
|
+
case "boolean":
|
|
264
|
+
checkBoolean(value, where);
|
|
265
|
+
break;
|
|
242
266
|
case "array":
|
|
243
267
|
checkArray(value, node, path, where);
|
|
244
268
|
break;
|
|
@@ -258,15 +282,17 @@ function assertConfig(value) {
|
|
|
258
282
|
* The page script (`DATA.allowed`) and the exporter (`key.json`) both read this one result, so a
|
|
259
283
|
* stored value counts as an answer on the page exactly when it counts in the export.
|
|
260
284
|
*/
|
|
261
|
-
const allowedValues = (questions) => ({
|
|
285
|
+
const allowedValues = (questions, asksFamiliarity) => ({
|
|
262
286
|
questions: Object.fromEntries(questions.map((q) => [q.id, q.options.map((o) => o.value)])),
|
|
263
287
|
// The answers that need a reason, per question: the page and the export read the same list.
|
|
264
288
|
needs_why: Object.fromEntries(questions.map((q) => [
|
|
265
289
|
q.id,
|
|
266
290
|
q.options.filter((o) => o.ask_why !== undefined).map((o) => o.value),
|
|
267
291
|
])),
|
|
268
|
-
familiarity:
|
|
292
|
+
// Empty when the page asks no familiarity: the export then expects no group document at all.
|
|
293
|
+
familiarity: asksFamiliarity ? FAMILIARITY.map((o) => o.value) : [],
|
|
269
294
|
});
|
|
295
|
+
const asksFamiliarity = (cfg) => cfg.familiarity !== false;
|
|
270
296
|
/** Two values that read the same once case and surrounding space are gone. */
|
|
271
297
|
const readsAs = (s) => s.trim().toLowerCase();
|
|
272
298
|
/** The first entry whose reading repeats an earlier one, with the index of that earlier one. */
|
|
@@ -330,6 +356,9 @@ function checkPractice(cfg) {
|
|
|
330
356
|
}
|
|
331
357
|
function checkConfig(cfg) {
|
|
332
358
|
checkGroups(cfg);
|
|
359
|
+
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
360
|
+
fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is false — remove one of the two`);
|
|
361
|
+
}
|
|
333
362
|
cfg.questions.forEach((q, qn) => {
|
|
334
363
|
checkQuestion(q, qn, cfg.questions.slice(0, qn).map((x) => x.id));
|
|
335
364
|
});
|
|
@@ -337,7 +366,41 @@ function checkConfig(cfg) {
|
|
|
337
366
|
}
|
|
338
367
|
/** The practice as the page shows it and the key keeps it: neutral ids P1, P2 … in the given order. */
|
|
339
368
|
const practiceOf = (cfg) => cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
|
|
340
|
-
|
|
369
|
+
/** The items of each line, in items.json order; a line's list is never empty. */
|
|
370
|
+
const linesOf = (cfg) => cfg.items.reduce((byLine, item, n) => {
|
|
371
|
+
if (item.unit === undefined)
|
|
372
|
+
return byLine;
|
|
373
|
+
const part = { item, unit: item.unit, n };
|
|
374
|
+
const seen = byLine.get(item.unit.id);
|
|
375
|
+
return byLine.set(item.unit.id, seen ? [seen[0], ...seen.slice(1), part] : [part]);
|
|
376
|
+
}, new Map());
|
|
377
|
+
/** One part against the first part of its line: same count, in range, not repeated, same group. */
|
|
378
|
+
function checkPart({ item, unit, n }, parts) {
|
|
379
|
+
const [first] = parts;
|
|
380
|
+
const at = `items[${String(n)}]`;
|
|
381
|
+
if (unit.text !== first.unit.text)
|
|
382
|
+
fail(`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`);
|
|
383
|
+
if (unit.of !== first.unit.of)
|
|
384
|
+
fail(`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`);
|
|
385
|
+
if (unit.part < 1 || unit.part > unit.of)
|
|
386
|
+
fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
|
|
387
|
+
const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
|
|
388
|
+
if (earlier !== undefined)
|
|
389
|
+
fail(`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`);
|
|
390
|
+
if (item.group !== first.item.group)
|
|
391
|
+
fail(`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`);
|
|
392
|
+
}
|
|
393
|
+
function checkUnits(cfg) {
|
|
394
|
+
for (const [line, parts] of linesOf(cfg)) {
|
|
395
|
+
parts.forEach((p) => {
|
|
396
|
+
checkPart(p, parts);
|
|
397
|
+
});
|
|
398
|
+
const [first] = parts;
|
|
399
|
+
const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find((k) => !parts.some((p) => p.unit.part === k));
|
|
400
|
+
if (missing !== undefined)
|
|
401
|
+
fail(`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
341
404
|
function checkItemFields(cfg) {
|
|
342
405
|
const sids = [];
|
|
343
406
|
cfg.items.forEach((item, n) => {
|
|
@@ -354,6 +417,11 @@ function checkItemFields(cfg) {
|
|
|
354
417
|
fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
|
|
355
418
|
});
|
|
356
419
|
}
|
|
420
|
+
/** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
|
|
421
|
+
const visibleTexts = (item) => [
|
|
422
|
+
...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
|
|
423
|
+
[UNIT_FIELD, item.unit?.text ?? ""],
|
|
424
|
+
];
|
|
357
425
|
const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
358
426
|
/**
|
|
359
427
|
* A source id typed into a visible field gives the source away as well as a field would. Short ids
|
|
@@ -366,7 +434,7 @@ function checkNoSourceShown(cfg) {
|
|
|
366
434
|
for (const item of cfg.items) {
|
|
367
435
|
for (const sid of long) {
|
|
368
436
|
const word = new RegExp(`(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`, "u");
|
|
369
|
-
const field =
|
|
437
|
+
const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
|
|
370
438
|
if (field !== undefined) {
|
|
371
439
|
fail(`the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`);
|
|
372
440
|
}
|
|
@@ -407,11 +475,21 @@ function assignIds(items, seed, keyPath) {
|
|
|
407
475
|
]);
|
|
408
476
|
return { ids, entries };
|
|
409
477
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}))
|
|
478
|
+
const shownFields = (item) => ({
|
|
479
|
+
...Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
|
|
480
|
+
const v = item[f];
|
|
481
|
+
return v === undefined ? [] : [[f, v]];
|
|
482
|
+
})),
|
|
483
|
+
...(item.unit === undefined
|
|
484
|
+
? {}
|
|
485
|
+
: {
|
|
486
|
+
[UNIT_FIELD]: {
|
|
487
|
+
text: item.unit.text,
|
|
488
|
+
part: item.unit.part,
|
|
489
|
+
of: item.unit.of,
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
});
|
|
415
493
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
416
494
|
function itemsOnPage(cfg, keyPath) {
|
|
417
495
|
const knows = cfg.labeller_knows;
|
|
@@ -432,9 +510,11 @@ function payloadOf(cfg, page) {
|
|
|
432
510
|
title: cfg.title,
|
|
433
511
|
intro: cfg.intro ?? "",
|
|
434
512
|
groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
|
|
435
|
-
familiarity_question: cfg
|
|
436
|
-
|
|
437
|
-
|
|
513
|
+
familiarity_question: asksFamiliarity(cfg)
|
|
514
|
+
? (cfg.familiarity_question ?? "How well do you know {group}?")
|
|
515
|
+
: "",
|
|
516
|
+
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
517
|
+
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
438
518
|
questions: cfg.questions,
|
|
439
519
|
practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
|
|
440
520
|
items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
|
|
@@ -478,7 +558,7 @@ function keyOf(cfg, page, earlier) {
|
|
|
478
558
|
questions: cfg.questions.map((q) => q.id),
|
|
479
559
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
480
560
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
481
|
-
allowed: allowedValues(cfg.questions),
|
|
561
|
+
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
482
562
|
// The practice is scored against this at export; its answers never go into the labels.
|
|
483
563
|
practice: {
|
|
484
564
|
round: practiceRound(cfg),
|
|
@@ -503,6 +583,7 @@ export function prepareBuild(configPath, outDir) {
|
|
|
503
583
|
assertConfig(cfg);
|
|
504
584
|
checkConfig(cfg);
|
|
505
585
|
checkItemFields(cfg);
|
|
586
|
+
checkUnits(cfg);
|
|
506
587
|
checkNoSourceShown(cfg);
|
|
507
588
|
mkdirSync(outDir, { recursive: true });
|
|
508
589
|
const keyPath = join(outDir, "key.json");
|
|
@@ -658,6 +739,14 @@ function checkDump(dump, key) {
|
|
|
658
739
|
}
|
|
659
740
|
}
|
|
660
741
|
}
|
|
742
|
+
/** A page built with `familiarity: false` asks no group anything: a group document came from elsewhere. */
|
|
743
|
+
function checkNoFamiliarity(dump, allowed) {
|
|
744
|
+
const groups = isObject(dump["groups"]) ? dump["groups"] : {};
|
|
745
|
+
const [first] = Object.keys(groups).sort(byText);
|
|
746
|
+
if (allowed.familiarity.length === 0 && first !== undefined) {
|
|
747
|
+
fail(`groups/${first}: familiarity is off for this page (items.json \`familiarity: false\`), so no group document is expected — take it out of the dump`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
661
750
|
/** The key's `allowed` lists, or a refusal naming a key made by an older build. */
|
|
662
751
|
function assertAllowed(a, key, keyPath) {
|
|
663
752
|
const questions = isObject(a) ? a["questions"] : undefined;
|
|
@@ -818,7 +907,9 @@ function exportRound(study, round, items, at) {
|
|
|
818
907
|
function labelsOf(dump, ctx, day) {
|
|
819
908
|
const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
|
|
820
909
|
const items = onPage.map(([nid, meta]) => labelOf(nid, meta, docOf(dump["answers"], nid), ctx));
|
|
821
|
-
const groups =
|
|
910
|
+
const groups = ctx.allowed.familiarity.length
|
|
911
|
+
? groupsOf([...new Set(onPage.map(([, m]) => m.group))], dump["groups"], ctx.allowed)
|
|
912
|
+
: {};
|
|
822
913
|
return {
|
|
823
914
|
study: ctx.key.study,
|
|
824
915
|
exported: day,
|
|
@@ -839,6 +930,7 @@ export function exportLabels(dumpPath, keyPath, outDir, day) {
|
|
|
839
930
|
const allowed = key.allowed;
|
|
840
931
|
assertAllowed(allowed, key, keyPath);
|
|
841
932
|
checkDump(dump, key);
|
|
933
|
+
checkNoFamiliarity(dump, allowed);
|
|
842
934
|
const out = labelsOf(dump, { key, allowed }, day);
|
|
843
935
|
mkdirSync(outDir, { recursive: true });
|
|
844
936
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|