paperlint 13.0.3 → 13.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/CLAUDE.md +9 -1
- package/dist/adapters/git/index.d.ts.map +1 -1
- package/dist/adapters/git/index.js +13 -0
- package/dist/adapters/git/index.js.map +1 -1
- package/dist/adapters/study-registry/index.d.ts +142 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +160 -1
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +43 -0
- package/dist/domain/decide-first.d.ts.map +1 -1
- package/dist/domain/decide-first.js +45 -2
- package/dist/domain/decide-first.js.map +1 -1
- package/dist/domain/study-gates.d.ts +96 -0
- package/dist/domain/study-gates.d.ts.map +1 -0
- package/dist/domain/study-gates.js +56 -0
- package/dist/domain/study-gates.js.map +1 -0
- package/dist/domain/study-mode.d.ts +6 -0
- package/dist/domain/study-mode.d.ts.map +1 -1
- package/dist/domain/study-mode.js.map +1 -1
- package/dist/domain/study-registry.d.ts +24 -4
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +39 -2
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/domain/study-result.d.ts +151 -0
- package/dist/domain/study-result.d.ts.map +1 -0
- package/dist/domain/study-result.js +175 -0
- package/dist/domain/study-result.js.map +1 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -1
- package/dist/outputs-before-decision-rule.js +37 -3
- package/dist/outputs-before-decision-rule.js.map +1 -1
- package/dist/ports/git-history.d.ts +12 -0
- package/dist/ports/git-history.d.ts.map +1 -1
- package/dist/ports/study-registry-codec.d.ts +3 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
- package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
- package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
- package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
- package/dist/study-modes.d.ts +3 -0
- package/dist/study-modes.d.ts.map +1 -1
- package/dist/study-modes.js +8 -1
- package/dist/study-modes.js.map +1 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +1 -0
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-result-rule.d.ts +12 -0
- package/dist/study-result-rule.d.ts.map +1 -0
- package/dist/study-result-rule.js +87 -0
- package/dist/study-result-rule.js.map +1 -0
- package/docs/design/study-gates.md +208 -0
- package/docs/rules/repro/outputs-before-decision.md +8 -0
- package/docs/rules/repro/study-registered.md +38 -15
- package/docs/rules/repro/study-result.md +100 -0
- package/docs/rules.md +1 -0
- package/eslint.config.mjs +1 -1
- package/hooks/hooks.harness.ts +51 -13
- package/hooks/paper-skills-nudge.hook.d.mts +22 -0
- package/hooks/paper-skills-nudge.hook.mjs +85 -23
- package/hooks/paper-skills-nudge.test.ts +82 -4
- package/package.json +2 -1
- package/plugin/hooks/hooks.json +7 -2
- package/schemas/plan.v2.schema.json +341 -0
- package/skills/argument-arc/SKILL.md.spec.ts +1 -1
- package/skills/build-label-page/SKILL.md +85 -54
- package/skills/build-label-page/SKILL.md.spec.ts +85 -53
- package/skills/build-label-page/build-label-page.test.ts +10 -941
- package/skills/build-label-page/references/checklist.md +45 -3
- package/skills/build-label-page/references/closed-choice-generator.md +88 -0
- package/skills/build-label-page/scripts/label-page-cli.js +72 -0
- package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
- package/skills/build-label-page/scripts/label-page-commands.js +14 -0
- package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
- package/skills/build-label-page/scripts/label-page-core.js +850 -0
- package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
- package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
- package/skills/build-label-page/scripts/label-page-html.js +33 -0
- package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
- package/skills/build-label-page/scripts/label-page-html.ts +43 -0
- package/skills/build-label-page/scripts/label-page.js +70 -0
- package/skills/build-label-page/scripts/label-page.test.ts +71 -0
- package/skills/build-label-page/scripts/label-page.ts +71 -0
- package/skills/build-label-page/scripts/page-template.html +513 -0
- package/skills/find-venue/SKILL.md.spec.ts +1 -1
- package/skills/paper-status/SKILL.md.spec.ts +1 -1
- package/skills/render-paper/SKILL.md.spec.ts +1 -1
- package/src/adapters/git/index.test.ts +33 -0
- package/src/adapters/git/index.ts +19 -0
- package/src/adapters/study-registry/gates.test.ts +181 -0
- package/src/adapters/study-registry/index.ts +219 -1
- package/src/adapters/study-registry/mode.test.ts +6 -0
- package/src/cli.ts +3 -0
- package/src/domain/decide-first.test.ts +131 -0
- package/src/domain/decide-first.ts +92 -2
- package/src/domain/study-gates.ts +125 -0
- package/src/domain/study-measurements.test.ts +71 -0
- package/src/domain/study-mode.ts +6 -0
- package/src/domain/study-registry.ts +64 -2
- package/src/domain/study-result.test.ts +300 -0
- package/src/domain/study-result.ts +358 -0
- package/src/outputs-before-decision-rule.test.ts +115 -2
- package/src/outputs-before-decision-rule.ts +50 -2
- package/src/ports/git-history.ts +12 -0
- package/src/ports/study-registry-codec.ts +3 -0
- package/src/rule-cases.test.ts +74 -0
- package/src/studies.test.ts +3 -0
- package/src/study-modes.ts +10 -1
- package/src/study-registry-rule.ts +1 -0
- package/src/study-result-rule.test.ts +281 -0
- package/src/study-result-rule.ts +123 -0
- package/skills/build-label-page/references/example-generator.py +0 -803
|
@@ -1,803 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
example-generator.py — items.json -> a blind, single-page labelling app (HTML), and the answers back out.
|
|
4
|
-
|
|
5
|
-
A TEMPLATE, not a library: copy it next to the study, rename it, change the questions and the
|
|
6
|
-
allow-list, keep the guards. Standard library only. No network.
|
|
7
|
-
|
|
8
|
-
python3 example-generator.py build items.json out/ -> out/page.html, out/key.json
|
|
9
|
-
python3 example-generator.py export dump.json out/key.json data/ [--date YYYY-MM-DD]
|
|
10
|
-
-> data/<study>-labels-<date>.json
|
|
11
|
-
|
|
12
|
-
`build` makes a page that stores each answer in the artifact's `db` store (the `db` capability of a
|
|
13
|
-
claude.ai Artifact; publish page.html with `capabilities: {db: {}}`). `export` turns what the store
|
|
14
|
-
holds into one JSON file for the study's data/ folder. Answers and familiarity values outside the
|
|
15
|
-
offered options are not counted (page and export read the same `allowed` lists): the item or group is
|
|
16
|
-
marked incomplete and reported (`invalid_answers`, `invalid_familiarity`). `dump.json` is the store's two collections:
|
|
17
|
-
|
|
18
|
-
{"answers": {"G001": {"answers": {"q": "v"}, "savedAt": "...", "source": "page"}, ...},
|
|
19
|
-
"groups": {"python": {"familiarity": "well", "savedAt": "...", "source": "page"}, ...}}
|
|
20
|
-
|
|
21
|
-
items.json is checked once against the declared CONFIG_SCHEMA (`python3 example-generator.py schema`
|
|
22
|
-
prints it): every field's type, required or optional, non-empty strings, ids as strings of letters,
|
|
23
|
-
digits, - and _. A bad field stops the build and is named by its path, e.g. `questions[0].options[1].label`.
|
|
24
|
-
|
|
25
|
-
items.json:
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
"study": "demo-gold", # file-name stem
|
|
29
|
-
"title": "Label these", # 2-4 words; the page title
|
|
30
|
-
"intro": "One or two sentences for the labeller.",
|
|
31
|
-
"seed": 7, # fixes the order; change it and every id changes
|
|
32
|
-
"groups": {"python": "Python", "go": "Go"},
|
|
33
|
-
"labeller_knows": ["python"], # optional: items of other groups are not put on the page
|
|
34
|
-
"familiarity_question": "How well do you know {group}?", # optional
|
|
35
|
-
"questions": [{"id": "label", "text": "Is this correct?", "short": "correct?", # short: shown in "still needs"
|
|
36
|
-
"options": [{"value": "yes", "label": "Yes"}, {"value": "no", "label": "No"}]}],
|
|
37
|
-
"items": [{"source_id": "case-0412-b", "group": "python", "body": "...", "code": "...",
|
|
38
|
-
"context": "..."}]
|
|
39
|
-
}
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
import argparse
|
|
43
|
-
import hashlib
|
|
44
|
-
import json
|
|
45
|
-
import re
|
|
46
|
-
import sys
|
|
47
|
-
from datetime import date
|
|
48
|
-
from pathlib import Path
|
|
49
|
-
|
|
50
|
-
# ── THE LEAK GUARD ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
-
# What an item may carry. `PAGE_FIELDS` reach the labeller; `PRIVATE_FIELDS` go to key.json only.
|
|
52
|
-
# Anything else on an item — a model answer, a checker verdict, a source label, a difficulty tag —
|
|
53
|
-
# stops the build: a blind page fails by what leaked in, and a field nobody listed is exactly the
|
|
54
|
-
# one nobody thought of. A NEW FIELD IS ADDED HERE ON PURPOSE, after asking "does this tell the
|
|
55
|
-
# labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
56
|
-
PAGE_FIELDS = ("group", "title", "body", "code", "context")
|
|
57
|
-
PRIVATE_FIELDS = ("source_id",)
|
|
58
|
-
ALLOWED_FIELDS = frozenset(PAGE_FIELDS) | frozenset(PRIVATE_FIELDS)
|
|
59
|
-
|
|
60
|
-
FAMILIARITY = (
|
|
61
|
-
{"value": "well", "label": "Well"},
|
|
62
|
-
{"value": "somewhat", "label": "Somewhat"},
|
|
63
|
-
{"value": "not_really", "label": "Not really"},
|
|
64
|
-
)
|
|
65
|
-
# An id is a key of a plain JavaScript object on the page, so a name Object.prototype already has
|
|
66
|
-
# is refused: `answers["__proto__"] = v` sets the prototype and saves nothing, and an unanswered
|
|
67
|
-
# `answers["constructor"]` reads back a function instead of nothing.
|
|
68
|
-
RESERVED_NAMES = ("__proto__", "constructor", "prototype", "hasOwnProperty", "isPrototypeOf",
|
|
69
|
-
"propertyIsEnumerable", "toLocaleString", "toString", "valueOf",
|
|
70
|
-
"__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__")
|
|
71
|
-
NAME_PATTERN = r"(?!(?:" + "|".join(RESERVED_NAMES) + r")$)[A-Za-z0-9_-]+"
|
|
72
|
-
SOURCES = ("page", "chat")
|
|
73
|
-
|
|
74
|
-
# ── THE CONFIG SCHEMA ──────────────────────────────────────────────────────────────────────────
|
|
75
|
-
# items.json is checked ONCE, against this declaration, before anything is built: every field's
|
|
76
|
-
# type, whether it is required, non-empty strings, ids as strings matching NAME_PATTERN. A refusal
|
|
77
|
-
# names the field's path (`questions[0].options[1].label`). A NEW FIELD IS A NEW LINE HERE; the
|
|
78
|
-
# build-label-page test walks this very table (`schema` prints it) with a wrong type, null and an
|
|
79
|
-
# empty value for each path, so a field without a line is a test failure, not a later review finding.
|
|
80
|
-
# The leak guard on items (ALLOWED_FIELDS) stays separate and stays closed.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def string(required=True, pattern=None):
|
|
84
|
-
node = {"kind": "string", "required": required}
|
|
85
|
-
return {**node, "pattern": pattern} if pattern else node
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def integer(required=True):
|
|
89
|
-
return {"kind": "integer", "required": required}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def array(items, required=True, min=0):
|
|
93
|
-
return {"kind": "array", "required": required, "min": min, "items": items}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def mapping(values, key_pattern, required=True, min=0):
|
|
97
|
-
return {"kind": "map", "required": required, "min": min, "key_pattern": key_pattern, "values": values}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def obj(fields, required=True, closed=True):
|
|
101
|
-
return {"kind": "object", "required": required, "closed": closed, "fields": fields}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
ITEM_SCHEMA = obj(
|
|
105
|
-
{
|
|
106
|
-
"source_id": string(),
|
|
107
|
-
**{f: string(required=f in ("group", "body")) for f in PAGE_FIELDS},
|
|
108
|
-
},
|
|
109
|
-
closed=False, # an unlisted item field is the leak guard's refusal, with its own message
|
|
110
|
-
)
|
|
111
|
-
OPTION_SCHEMA = obj({"value": string(), "label": string()})
|
|
112
|
-
QUESTION_SCHEMA = obj(
|
|
113
|
-
{
|
|
114
|
-
"id": string(pattern=NAME_PATTERN),
|
|
115
|
-
"text": string(),
|
|
116
|
-
"short": string(required=False),
|
|
117
|
-
"options": array(OPTION_SCHEMA, min=2),
|
|
118
|
-
}
|
|
119
|
-
)
|
|
120
|
-
CONFIG_SCHEMA = obj(
|
|
121
|
-
{
|
|
122
|
-
"study": string(pattern=NAME_PATTERN), # becomes a file name
|
|
123
|
-
"title": string(),
|
|
124
|
-
"intro": string(required=False),
|
|
125
|
-
"seed": integer(),
|
|
126
|
-
"groups": mapping(string(), NAME_PATTERN, min=1), # ids become store document ids
|
|
127
|
-
"labeller_knows": array(string(pattern=NAME_PATTERN), required=False),
|
|
128
|
-
"familiarity_question": string(required=False),
|
|
129
|
-
"questions": array(QUESTION_SCHEMA, min=1),
|
|
130
|
-
"items": array(ITEM_SCHEMA, min=1),
|
|
131
|
-
}
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def at(path, name):
|
|
136
|
-
return f"{path}.{name}" if path else name
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def check_value(value, node, path):
|
|
140
|
-
"""Refuse the first place `value` departs from `node`; the message starts with the field's path."""
|
|
141
|
-
where = path or "items.json"
|
|
142
|
-
kind = node["kind"]
|
|
143
|
-
if kind == "string":
|
|
144
|
-
if not isinstance(value, str) or not value.strip():
|
|
145
|
-
fail(f"{where}: must be a non-empty string, got {value!r}")
|
|
146
|
-
if "pattern" in node and not re.fullmatch(node["pattern"], value):
|
|
147
|
-
fail(f"{where}: {value!r} must be letters, digits, - and _ only")
|
|
148
|
-
elif kind == "integer":
|
|
149
|
-
if not isinstance(value, int) or isinstance(value, bool):
|
|
150
|
-
fail(f"{where}: must be an integer, got {value!r}")
|
|
151
|
-
elif kind == "array":
|
|
152
|
-
if not isinstance(value, list):
|
|
153
|
-
fail(f"{where}: must be a list, got {value!r}")
|
|
154
|
-
if len(value) < node["min"]:
|
|
155
|
-
fail(f"{where}: needs {node['min']} or more entries, has {len(value)}")
|
|
156
|
-
for n, entry in enumerate(value):
|
|
157
|
-
check_value(entry, node["items"], f"{path}[{n}]")
|
|
158
|
-
elif kind == "map":
|
|
159
|
-
if not isinstance(value, dict):
|
|
160
|
-
fail(f"{where}: must be an object, got {value!r}")
|
|
161
|
-
if len(value) < node["min"]:
|
|
162
|
-
fail(f"{where}: needs {node['min']} or more entries, has {len(value)}")
|
|
163
|
-
for key, entry in value.items():
|
|
164
|
-
if not re.fullmatch(node["key_pattern"], key):
|
|
165
|
-
fail(f"{at(path, key)}: key must be letters, digits, - and _ only")
|
|
166
|
-
check_value(entry, node["values"], at(path, key))
|
|
167
|
-
else:
|
|
168
|
-
if not isinstance(value, dict):
|
|
169
|
-
fail(f"{where}: must be an object, got {value!r}")
|
|
170
|
-
for name, sub in node["fields"].items():
|
|
171
|
-
if name in value:
|
|
172
|
-
check_value(value[name], sub, at(path, name))
|
|
173
|
-
elif sub["required"]:
|
|
174
|
-
fail(f"{at(path, name)}: is required and missing")
|
|
175
|
-
if node["closed"]:
|
|
176
|
-
for name in value:
|
|
177
|
-
if name not in node["fields"]:
|
|
178
|
-
fail(f"{at(path, name)}: not a known field (known: {', '.join(node['fields'])})")
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def allowed_values(questions):
|
|
182
|
-
"""THE definition of a valid answer: the values the build offers, per question and for familiarity.
|
|
183
|
-
The page script (`DATA.allowed`) and the exporter (`key.json`) both read this one result, so a
|
|
184
|
-
stored value counts as an answer on the page exactly when it counts in the export."""
|
|
185
|
-
return {
|
|
186
|
-
"questions": {q["id"]: [o["value"] for o in q["options"]] for q in questions},
|
|
187
|
-
"familiarity": [o["value"] for o in FAMILIARITY],
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def fail(message):
|
|
192
|
-
sys.exit(f"REFUSED: {message}")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def read_json(path):
|
|
196
|
-
try:
|
|
197
|
-
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
198
|
-
except (OSError, ValueError) as e:
|
|
199
|
-
fail(f"cannot read {path} as JSON: {e}")
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def check_config(cfg):
|
|
203
|
-
check_value(cfg, CONFIG_SCHEMA, "")
|
|
204
|
-
# What a schema cannot say: references between fields, and values that must differ.
|
|
205
|
-
# The page heads each group with its label: two that read the same are two familiarity questions
|
|
206
|
-
# the labeller cannot tell apart, exported under different group ids.
|
|
207
|
-
heading = {}
|
|
208
|
-
for gid, label in cfg["groups"].items():
|
|
209
|
-
seen_as = label.strip().casefold()
|
|
210
|
-
if seen_as in heading:
|
|
211
|
-
fail(f"groups.{gid}: {label!r} reads the same as groups.{heading[seen_as]} — each group needs its own label")
|
|
212
|
-
heading[seen_as] = gid
|
|
213
|
-
for n, gid in enumerate(cfg.get("labeller_knows", [])):
|
|
214
|
-
if gid not in cfg["groups"]:
|
|
215
|
-
fail(f"labeller_knows[{n}]: {gid!r} is not in `groups`")
|
|
216
|
-
seen = set()
|
|
217
|
-
for qn, q in enumerate(cfg["questions"]):
|
|
218
|
-
if q["id"] in seen:
|
|
219
|
-
fail(f"questions[{qn}].id: {q['id']!r} is repeated")
|
|
220
|
-
seen.add(q["id"])
|
|
221
|
-
values = [o["value"] for o in q["options"]]
|
|
222
|
-
if len(set(values)) != len(values):
|
|
223
|
-
fail(f"questions[{qn}].options: values must be distinct, got {values}")
|
|
224
|
-
# Two buttons that read the same store different answers no one can tell apart afterwards.
|
|
225
|
-
shown = {}
|
|
226
|
-
for i, o in enumerate(q["options"]):
|
|
227
|
-
label = o["label"].strip().casefold()
|
|
228
|
-
if label in shown:
|
|
229
|
-
fail(f"questions[{qn}].options[{i}].label: {o['label']!r} reads the same as "
|
|
230
|
-
f"options[{shown[label]}].label — each button needs its own label")
|
|
231
|
-
shown[label] = i
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def check_items(cfg):
|
|
235
|
-
"""The leak guard, and the references between items and the config. Refuses; never drops a field quietly."""
|
|
236
|
-
ids = set()
|
|
237
|
-
for n, item in enumerate(cfg["items"]):
|
|
238
|
-
extra = sorted(set(item) - ALLOWED_FIELDS)
|
|
239
|
-
if extra:
|
|
240
|
-
fail(
|
|
241
|
-
f"item #{n} ({item.get('source_id', '?')}) carries {', '.join(extra)}, which is not in the "
|
|
242
|
-
f"allow-list {sorted(ALLOWED_FIELDS)}. A blind page shows the labeller the item and "
|
|
243
|
-
f"nothing about the answer or the source. Remove the field from items.json; add it to "
|
|
244
|
-
f"PAGE_FIELDS only if it is safe for the labeller to see."
|
|
245
|
-
)
|
|
246
|
-
sid = item["source_id"]
|
|
247
|
-
if sid in ids:
|
|
248
|
-
fail(f"items[{n}].source_id: {sid!r} is repeated")
|
|
249
|
-
ids.add(sid)
|
|
250
|
-
if item["group"] not in cfg["groups"]:
|
|
251
|
-
fail(f"items[{n}].group: {item['group']!r} is not in `groups`")
|
|
252
|
-
# A source id typed into a visible field gives the source away as well as a field would.
|
|
253
|
-
# Short ids ("7") would match ordinary text, so only ids of four characters or more are looked for.
|
|
254
|
-
for item in cfg["items"]:
|
|
255
|
-
for other in cfg["items"]:
|
|
256
|
-
sid = other["source_id"]
|
|
257
|
-
if len(sid) < 4:
|
|
258
|
-
continue
|
|
259
|
-
pattern = re.compile(r"(?<![\w-])" + re.escape(sid) + r"(?![\w-])")
|
|
260
|
-
for f in PAGE_FIELDS:
|
|
261
|
-
if pattern.search(item.get(f, "")):
|
|
262
|
-
fail(
|
|
263
|
-
f"the source id {sid!r} appears in the visible `{f}` of item {item['source_id']}. "
|
|
264
|
-
f"Take it out of the text (or use source ids that are not natural words)."
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def assign_ids(items, seed, key_path):
|
|
269
|
-
"""Neutral ids G001... in an order fixed by the seed, and kept when a key file already exists."""
|
|
270
|
-
known, entries = {}, {}
|
|
271
|
-
if key_path.exists():
|
|
272
|
-
old = read_json(key_path)
|
|
273
|
-
entries = old["ids"]
|
|
274
|
-
if old.get("seed") != seed:
|
|
275
|
-
fail(f"{key_path} was made with seed {old.get('seed')}; this config says {seed}. "
|
|
276
|
-
f"Answers are keyed by the old ids: restore the seed, or start a new study folder.")
|
|
277
|
-
known = {v["source_id"]: nid for nid, v in old["ids"].items()}
|
|
278
|
-
ids = dict(known)
|
|
279
|
-
fresh = sorted(
|
|
280
|
-
(i["source_id"] for i in items if i["source_id"] not in known),
|
|
281
|
-
key=lambda s: hashlib.sha256(f"{seed}:{s}".encode()).hexdigest(),
|
|
282
|
-
)
|
|
283
|
-
start = len(known)
|
|
284
|
-
for k, sid in enumerate(fresh, start=start + 1):
|
|
285
|
-
ids[sid] = f"G{k:03d}"
|
|
286
|
-
return ids, entries
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def build(config_path, out_dir):
|
|
290
|
-
cfg = read_json(config_path)
|
|
291
|
-
check_config(cfg)
|
|
292
|
-
check_items(cfg)
|
|
293
|
-
out = Path(out_dir)
|
|
294
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
295
|
-
# Only what the labeller can answer goes on the page, and only that gets an id.
|
|
296
|
-
knows = cfg.get("labeller_knows")
|
|
297
|
-
on_page = [i for i in cfg["items"] if knows is None or i["group"] in knows]
|
|
298
|
-
dropped = len(cfg["items"]) - len(on_page)
|
|
299
|
-
if not on_page:
|
|
300
|
-
fail("no item is left after `labeller_knows`")
|
|
301
|
-
ids, entries = assign_ids(on_page, cfg["seed"], out / "key.json")
|
|
302
|
-
on_page.sort(key=lambda i: ids[i["source_id"]])
|
|
303
|
-
|
|
304
|
-
shown = {}
|
|
305
|
-
for i in on_page:
|
|
306
|
-
shown[ids[i["source_id"]]] = {f: i[f] for f in PAGE_FIELDS if f in i}
|
|
307
|
-
used = [g for g in cfg["groups"] if any(i["group"] == g for i in on_page)]
|
|
308
|
-
payload = {
|
|
309
|
-
"title": cfg["title"],
|
|
310
|
-
"intro": cfg.get("intro", ""),
|
|
311
|
-
"groups": [{"id": g, "label": cfg["groups"][g]} for g in used],
|
|
312
|
-
"familiarity_question": cfg.get("familiarity_question", "How well do you know {group}?"),
|
|
313
|
-
"familiarity": list(FAMILIARITY),
|
|
314
|
-
"allowed": allowed_values(cfg["questions"]),
|
|
315
|
-
"questions": cfg["questions"],
|
|
316
|
-
"items": [{"id": nid, **fields} for nid, fields in shown.items()],
|
|
317
|
-
}
|
|
318
|
-
# `</` inside JSON inside a <script> would end the script early.
|
|
319
|
-
data = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
|
|
320
|
-
page = TEMPLATE.replace("__TITLE__", escape(cfg["title"])).replace("__DATA__", data)
|
|
321
|
-
(out / "page.html").write_text(page, encoding="utf-8", newline="\n")
|
|
322
|
-
|
|
323
|
-
group_of = {v["source_id"]: v["group"] for v in entries.values()}
|
|
324
|
-
group_of.update({i["source_id"]: i["group"] for i in cfg["items"]})
|
|
325
|
-
key = {
|
|
326
|
-
"study": cfg["study"],
|
|
327
|
-
"seed": cfg["seed"],
|
|
328
|
-
"questions": [q["id"] for q in cfg["questions"]],
|
|
329
|
-
# What counts as an answer: the same dict the page was given (`allowed_values`). `export`
|
|
330
|
-
# checks every stored answer and familiarity against it, so the build's options decide, not
|
|
331
|
-
# whatever the store holds.
|
|
332
|
-
"allowed": allowed_values(cfg["questions"]),
|
|
333
|
-
"ids": {
|
|
334
|
-
nid: {"source_id": sid, "group": group_of[sid], "on_page": nid in shown}
|
|
335
|
-
for sid, nid in sorted(ids.items(), key=lambda kv: kv[1])
|
|
336
|
-
},
|
|
337
|
-
}
|
|
338
|
-
(out / "key.json").write_text(json.dumps(key, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
339
|
-
print(f"built {out / 'page.html'}: {len(on_page)} items, {len(used)} groups"
|
|
340
|
-
f"{f', {dropped} left out (labeller does not know their group)' if dropped else ''}")
|
|
341
|
-
print(f"wrote {out / 'key.json'}: the only link from G-ids to source ids; it is NOT part of the page")
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def escape(text):
|
|
345
|
-
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
ANSWER_DOC_FIELDS = ("answers", "savedAt", "source")
|
|
349
|
-
GROUP_DOC_FIELDS = ("familiarity", "savedAt", "source")
|
|
350
|
-
DOC_FIELD_TYPES = (
|
|
351
|
-
("answers", lambda v: isinstance(v, dict), "an object {question: value}"),
|
|
352
|
-
("familiarity", lambda v: v is None or isinstance(v, str), "a familiarity value (a string) or null"),
|
|
353
|
-
("savedAt", lambda v: v is None or isinstance(v, str), "a timestamp string or null"),
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def check_dump(dump, key):
|
|
358
|
-
"""The whole store against the key, ONCE, before anything is read from it. Every name in it must be
|
|
359
|
-
one the key knows: an item id, a group, a question id, a field of the document; an item or group
|
|
360
|
-
must also still be on the page, or a rebuild that left it out would drop its stored answer. A name the export
|
|
361
|
-
does not read would otherwise be dropped without a word — a misspelled question key in a chat merge
|
|
362
|
-
(`boundry`), a misspelled group (`pyhton`) — and its answer lost from the labels. Refuses, naming the
|
|
363
|
-
path; the fix is one edit in the dump. Every document also says where it came from: the page always
|
|
364
|
-
writes `source: "page"`, so a document without one was merged by hand."""
|
|
365
|
-
known = {
|
|
366
|
-
"answers": (set(key["ids"]), ANSWER_DOC_FIELDS),
|
|
367
|
-
"groups": ({meta["group"] for meta in key["ids"].values()}, GROUP_DOC_FIELDS),
|
|
368
|
-
}
|
|
369
|
-
shown = [meta for meta in key["ids"].values() if meta["on_page"]]
|
|
370
|
-
on_page = {
|
|
371
|
-
"answers": {nid for nid, meta in key["ids"].items() if meta["on_page"]},
|
|
372
|
-
"groups": {meta["group"] for meta in shown},
|
|
373
|
-
}
|
|
374
|
-
if not isinstance(dump, dict):
|
|
375
|
-
fail(f"store: {type(dump).__name__} — must be an object {{answers, groups}}")
|
|
376
|
-
for kind in known:
|
|
377
|
-
if kind in dump and not isinstance(dump[kind], dict):
|
|
378
|
-
fail(f"{kind}: {type(dump[kind]).__name__} — must be an object {{id: stored document}}")
|
|
379
|
-
for top in sorted(set(dump) - set(known)):
|
|
380
|
-
fail(f"{top}: the store has no such collection (expected {', '.join(known)})")
|
|
381
|
-
for kind, (names, fields) in known.items():
|
|
382
|
-
for name, doc in sorted(dump.get(kind, {}).items()):
|
|
383
|
-
at = f"{kind}/{name}"
|
|
384
|
-
if name not in names:
|
|
385
|
-
fail(f"{at}: the key does not know this {kind[:-1]} ({', '.join(sorted(names))})")
|
|
386
|
-
if name not in on_page[kind]:
|
|
387
|
-
fail(f"{at}: stored, but no longer on the page (a rebuild left it out), so the export would "
|
|
388
|
-
f"drop it; rebuild with it on the page, or take it out of the dump on purpose")
|
|
389
|
-
if not isinstance(doc, dict):
|
|
390
|
-
fail(f"{at}: must be an object with {', '.join(fields)}")
|
|
391
|
-
for field in sorted(set(doc) - set(fields)):
|
|
392
|
-
fail(f"{at}/{field}: not a field of a stored {kind[:-1]} ({', '.join(fields)})")
|
|
393
|
-
if "source" not in doc:
|
|
394
|
-
fail(f"{at}: no source; add \"source\": \"chat\" or \"page\" (one of {SOURCES})")
|
|
395
|
-
if not isinstance(doc["source"], str) or doc["source"] not in SOURCES:
|
|
396
|
-
fail(f"{at}/source: {doc['source']!r}; expected one of {SOURCES}")
|
|
397
|
-
# The types the export reads, so a hand merge is refused by path rather than crashing it.
|
|
398
|
-
for field, ok, what in DOC_FIELD_TYPES:
|
|
399
|
-
if field in doc and not ok(doc[field]):
|
|
400
|
-
fail(f"{at}/{field}: {doc[field]!r} — must be {what}")
|
|
401
|
-
for q, v in sorted(doc.get("answers", {}).items()):
|
|
402
|
-
if q not in key["questions"]:
|
|
403
|
-
fail(f"{at}/answers/{q}: not a question of the key ({', '.join(key['questions'])})")
|
|
404
|
-
if v is not None and not isinstance(v, str):
|
|
405
|
-
fail(f"{at}/answers/{q}: {v!r} — must be an option value (a string) or null")
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def export(dump_path, key_path, out_dir, day):
|
|
409
|
-
dump = read_json(dump_path)
|
|
410
|
-
key = read_json(key_path)
|
|
411
|
-
qids = key["questions"]
|
|
412
|
-
allowed = key.get("allowed")
|
|
413
|
-
if (not isinstance(allowed, dict) or "familiarity" not in allowed
|
|
414
|
-
or any(q not in allowed.get("questions", {}) for q in qids)):
|
|
415
|
-
fail(f"{key_path} has no `allowed` values for every question and for familiarity (made by an older build). "
|
|
416
|
-
f"Run `build` again with the same items.json and seed: ids are kept, the key gains them.")
|
|
417
|
-
check_dump(dump, key)
|
|
418
|
-
answers = dump.get("answers", {})
|
|
419
|
-
groups = dump.get("groups", {})
|
|
420
|
-
|
|
421
|
-
items, group_ids = [], []
|
|
422
|
-
for nid, meta in key["ids"].items():
|
|
423
|
-
if not meta["on_page"]:
|
|
424
|
-
continue
|
|
425
|
-
doc = answers.get(nid, {})
|
|
426
|
-
raw = {q: doc.get("answers", {}).get(q) for q in qids}
|
|
427
|
-
# An answer outside the question's allowed values (a typo in a chat answer, an option that
|
|
428
|
-
# was removed or renamed between builds) is MARKED, not refused and not dropped: the item is
|
|
429
|
-
# incomplete, the raw value is kept under `invalid_answers`, and the report names the item.
|
|
430
|
-
# Refusing would hold every good label hostage to one typo at the moment the export must
|
|
431
|
-
# happen (checklist rule 6); dropping it would lose what the labeller said. A clear report
|
|
432
|
-
# beats both, and the fix is one line in the dump (`"source": "chat"` stays as it was).
|
|
433
|
-
invalid = {q: v for q, v in raw.items() if v is not None and v not in allowed["questions"][q]}
|
|
434
|
-
given = {q: (None if q in invalid else v) for q, v in raw.items()}
|
|
435
|
-
items.append({
|
|
436
|
-
"id": nid,
|
|
437
|
-
"source_id": meta["source_id"],
|
|
438
|
-
"group": meta["group"],
|
|
439
|
-
"answers": given,
|
|
440
|
-
"invalid_answers": invalid,
|
|
441
|
-
"complete": all(v is not None for v in given.values()),
|
|
442
|
-
"source": doc["source"] if doc else None,
|
|
443
|
-
"saved_at": doc.get("savedAt"),
|
|
444
|
-
})
|
|
445
|
-
if meta["group"] not in group_ids:
|
|
446
|
-
group_ids.append(meta["group"])
|
|
447
|
-
# Same rule as for item answers: a familiarity outside the options is marked, not counted as rated.
|
|
448
|
-
bad_fam = {g: groups[g]["familiarity"] for g in group_ids
|
|
449
|
-
if g in groups and groups[g].get("familiarity") is not None
|
|
450
|
-
and groups[g]["familiarity"] not in allowed["familiarity"]}
|
|
451
|
-
out = {
|
|
452
|
-
"study": key["study"],
|
|
453
|
-
"exported": day,
|
|
454
|
-
"questions": qids,
|
|
455
|
-
"items": items,
|
|
456
|
-
"groups": {
|
|
457
|
-
g: {"familiarity": None if g in bad_fam else groups.get(g, {}).get("familiarity"),
|
|
458
|
-
"invalid_familiarity": bad_fam.get(g),
|
|
459
|
-
"source": groups[g]["source"] if g in groups else None,
|
|
460
|
-
"saved_at": groups.get(g, {}).get("savedAt")}
|
|
461
|
-
for g in group_ids
|
|
462
|
-
},
|
|
463
|
-
"complete_items": sum(i["complete"] for i in items),
|
|
464
|
-
"invalid_items": sum(bool(i["invalid_answers"]) for i in items),
|
|
465
|
-
"invalid_groups": len(bad_fam),
|
|
466
|
-
"total_items": len(items),
|
|
467
|
-
}
|
|
468
|
-
target = Path(out_dir)
|
|
469
|
-
target.mkdir(parents=True, exist_ok=True)
|
|
470
|
-
path = target / f"{key['study']}-labels-{day}.json"
|
|
471
|
-
path.write_text(json.dumps(out, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
472
|
-
print(f"wrote {path}: {out['complete_items']} of {out['total_items']} items complete")
|
|
473
|
-
for i in items:
|
|
474
|
-
for q, v in i["invalid_answers"].items():
|
|
475
|
-
print(f" invalid {i['id']}: {q}={v!r} is not one of {', '.join(allowed['questions'][q])} (not counted)")
|
|
476
|
-
if not i["complete"]:
|
|
477
|
-
missing = [q for q, v in i["answers"].items() if v is None and q not in i["invalid_answers"]]
|
|
478
|
-
if missing:
|
|
479
|
-
print(f" incomplete {i['id']}: missing {', '.join(missing)}")
|
|
480
|
-
for g, v in out["groups"].items():
|
|
481
|
-
if g in bad_fam:
|
|
482
|
-
print(f" invalid group {g}: familiarity={bad_fam[g]!r} is not one of "
|
|
483
|
-
f"{', '.join(allowed['familiarity'])} (not counted as rated)")
|
|
484
|
-
elif v["familiarity"] is None:
|
|
485
|
-
print(f" group {g}: no familiarity answer")
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
# ── THE PAGE ───────────────────────────────────────────────────────────────────────────────────
|
|
489
|
-
# Written for the claude.ai viewer on a phone: the frame can be sized to the page, so the page
|
|
490
|
-
# never scrolls itself (no scrollIntoView), uses no fixed-position element, and says everything
|
|
491
|
-
# inline next to the thing it is about. "Next unanswered" is a FILTER, not a jump.
|
|
492
|
-
TEMPLATE = r"""<title>__TITLE__</title>
|
|
493
|
-
<style>
|
|
494
|
-
:root {
|
|
495
|
-
--bg: #ffffff; --fg: #1d2330; --muted: #5a6475; --card: #f3f5f9; --line: #d5dae4;
|
|
496
|
-
--accent: #2456d6; --on-accent: #ffffff; --ok: #1b7a3d; --warn: #9a5b00; --bad: #b3261e;
|
|
497
|
-
}
|
|
498
|
-
@media (prefers-color-scheme: dark) {
|
|
499
|
-
:root:not([data-theme="light"]) {
|
|
500
|
-
--bg: #14171f; --fg: #e8ebf2; --muted: #9aa4b6; --card: #1e232e; --line: #343b4a;
|
|
501
|
-
--accent: #7ea2ff; --on-accent: #0c1220; --ok: #5cc27f; --warn: #e0a23c; --bad: #ff8a80;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
:root[data-theme="dark"] {
|
|
505
|
-
--bg: #14171f; --fg: #e8ebf2; --muted: #9aa4b6; --card: #1e232e; --line: #343b4a;
|
|
506
|
-
--accent: #7ea2ff; --on-accent: #0c1220; --ok: #5cc27f; --warn: #e0a23c; --bad: #ff8a80;
|
|
507
|
-
}
|
|
508
|
-
* { box-sizing: border-box; }
|
|
509
|
-
body { margin: 0; background: var(--bg); color: var(--fg);
|
|
510
|
-
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
511
|
-
main { max-width: 42rem; margin: 0 auto; padding: 16px; }
|
|
512
|
-
h1 { font-size: 1.35rem; margin: 0 0 4px; }
|
|
513
|
-
h2 { font-size: 1.1rem; margin: 0; }
|
|
514
|
-
p { margin: 6px 0; }
|
|
515
|
-
.muted { color: var(--muted); font-size: 0.9rem; }
|
|
516
|
-
.banner { border: 1px solid var(--warn); color: var(--warn); border-radius: 8px; padding: 10px 12px; margin: 12px 0; }
|
|
517
|
-
.bar { height: 8px; background: var(--card); border-radius: 4px; overflow: hidden; margin: 8px 0; }
|
|
518
|
-
.bar > div { height: 100%; background: var(--ok); }
|
|
519
|
-
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 12px; }
|
|
520
|
-
button { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line);
|
|
521
|
-
border-radius: 8px; padding: 10px 14px; min-height: 44px; cursor: pointer; }
|
|
522
|
-
button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
523
|
-
button:disabled { opacity: 0.5; cursor: default; }
|
|
524
|
-
.left { background: var(--card); border-radius: 8px; padding: 10px 12px; margin: 0 0 12px; }
|
|
525
|
-
.left ul { margin: 6px 0 0; padding-left: 1.2rem; }
|
|
526
|
-
section.group { margin: 20px 0; }
|
|
527
|
-
.group > header { margin-bottom: 8px; }
|
|
528
|
-
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; margin: 10px 0; }
|
|
529
|
-
.card.done { border-color: var(--ok); }
|
|
530
|
-
.card .id { font-weight: 600; }
|
|
531
|
-
.card h3 { font-size: 1rem; margin: 4px 0; }
|
|
532
|
-
pre { overflow-x: auto; background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
|
|
533
|
-
padding: 8px; font-size: 0.85rem; white-space: pre-wrap; word-break: break-word; }
|
|
534
|
-
.q { margin: 12px 0 4px; font-weight: 600; }
|
|
535
|
-
.opts { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
536
|
-
.status { margin-top: 10px; font-size: 0.9rem; color: var(--muted); }
|
|
537
|
-
.status.missing { color: var(--warn); }
|
|
538
|
-
.status.failed { color: var(--bad); }
|
|
539
|
-
.status.saved { color: var(--ok); }
|
|
540
|
-
</style>
|
|
541
|
-
<main id="app"><p class="muted">Loading…</p></main>
|
|
542
|
-
<script type="application/json" id="data">__DATA__</script>
|
|
543
|
-
<script>
|
|
544
|
-
(function () {
|
|
545
|
-
"use strict";
|
|
546
|
-
var DATA = JSON.parse(document.getElementById("data").textContent);
|
|
547
|
-
var app = document.getElementById("app");
|
|
548
|
-
var state = {
|
|
549
|
-
answers: {}, // item id -> {answers, savedAt, source}, from the store
|
|
550
|
-
groups: {}, // group id -> {familiarity, savedAt, source}
|
|
551
|
-
mine: {}, // item/group key -> the answers this page has just written (ahead of the store)
|
|
552
|
-
inflight: {}, // item/group key -> writes not yet confirmed
|
|
553
|
-
failed: {}, // item/group key -> error code of the last refused write
|
|
554
|
-
loaded: { answers: false, groups: false },
|
|
555
|
-
store: "loading", // loading | ok | none
|
|
556
|
-
filter: "all"
|
|
557
|
-
};
|
|
558
|
-
var db = null;
|
|
559
|
-
var chains = {};
|
|
560
|
-
|
|
561
|
-
try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
|
|
562
|
-
|
|
563
|
-
// ── what "done" means ────────────────────────────────────────────────────────────────────
|
|
564
|
-
function answersOf(id) {
|
|
565
|
-
if (state.mine[id]) return state.mine[id];
|
|
566
|
-
var d = state.answers[id];
|
|
567
|
-
return d && d.answers ? d.answers : {};
|
|
568
|
-
}
|
|
569
|
-
// A stored value is an answer only if the build offers it (DATA.allowed, the same lists the
|
|
570
|
-
// export reads from key.json): after a rebuild that removed an option, or a typo merged from chat,
|
|
571
|
-
// the question is unanswered again and says so.
|
|
572
|
-
function valid(allowed, v) { return allowed.indexOf(v) !== -1; }
|
|
573
|
-
function missingFor(id) {
|
|
574
|
-
var a = answersOf(id);
|
|
575
|
-
return DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
|
|
576
|
-
}
|
|
577
|
-
function itemDone(id) { return missingFor(id).length === 0; }
|
|
578
|
-
function familiarity(g) {
|
|
579
|
-
var d = state.mine["group:" + g] || state.groups[g];
|
|
580
|
-
return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
|
|
581
|
-
}
|
|
582
|
-
function groupItems(g) { return DATA.items.filter(function (i) { return i.group === g; }); }
|
|
583
|
-
function groupDone(g) { return familiarity(g) !== undefined && groupItems(g).every(function (i) { return itemDone(i.id); }); }
|
|
584
|
-
|
|
585
|
-
// ── saving: on click, one write at a time per document ────────────────────────────────────
|
|
586
|
-
function write(key, coll, docId, body) {
|
|
587
|
-
state.inflight[key] = (state.inflight[key] || 0) + 1;
|
|
588
|
-
delete state.failed[key];
|
|
589
|
-
var step = function () { return db.collection(coll).doc(docId).set(body); };
|
|
590
|
-
var run = (chains[key] || Promise.resolve()).then(step, step);
|
|
591
|
-
chains[key] = run.then(function () {
|
|
592
|
-
state.inflight[key] -= 1;
|
|
593
|
-
if (!state.inflight[key]) delete state.mine[key];
|
|
594
|
-
render();
|
|
595
|
-
}, function (e) {
|
|
596
|
-
state.inflight[key] -= 1;
|
|
597
|
-
if (!state.inflight[key]) delete state.mine[key]; // a refused answer must not count as given
|
|
598
|
-
state.failed[key] = (e && e.code) || "unavailable";
|
|
599
|
-
render();
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
function canWrite() { return db && state.loaded.answers && state.loaded.groups && state.store === "ok"; }
|
|
603
|
-
function answer(id, qid, value) {
|
|
604
|
-
if (!canWrite()) return;
|
|
605
|
-
var next = Object.assign({}, answersOf(id));
|
|
606
|
-
next[qid] = value;
|
|
607
|
-
state.mine[id] = next;
|
|
608
|
-
write(id, "answers", id, { answers: next, savedAt: new Date().toISOString(), source: "page" });
|
|
609
|
-
render();
|
|
610
|
-
}
|
|
611
|
-
function setFamiliarity(g, value) {
|
|
612
|
-
if (!canWrite()) return;
|
|
613
|
-
var key = "group:" + g;
|
|
614
|
-
state.mine[key] = { familiarity: value };
|
|
615
|
-
write(key, "groups", g, { familiarity: value, savedAt: new Date().toISOString(), source: "page" });
|
|
616
|
-
render();
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// ── rendering (everything inline; no scrolling, no toasts) ────────────────────────────────
|
|
620
|
-
function el(tag, cls, text) {
|
|
621
|
-
var n = document.createElement(tag);
|
|
622
|
-
if (cls) n.className = cls;
|
|
623
|
-
if (text !== undefined) n.textContent = text;
|
|
624
|
-
return n;
|
|
625
|
-
}
|
|
626
|
-
function options(opts, current, onPick, disabled) {
|
|
627
|
-
var row = el("div", "opts");
|
|
628
|
-
opts.forEach(function (o) {
|
|
629
|
-
var b = el("button", "", o.label);
|
|
630
|
-
b.type = "button";
|
|
631
|
-
b.setAttribute("aria-pressed", String(current === o.value));
|
|
632
|
-
b.disabled = disabled;
|
|
633
|
-
b.addEventListener("click", function () { onPick(o.value); });
|
|
634
|
-
row.appendChild(b);
|
|
635
|
-
});
|
|
636
|
-
return row;
|
|
637
|
-
}
|
|
638
|
-
function timeOf(iso) {
|
|
639
|
-
var d = new Date(iso);
|
|
640
|
-
return isNaN(d) ? "" : d.toLocaleTimeString();
|
|
641
|
-
}
|
|
642
|
-
function statusLine(key, missing, savedAt) {
|
|
643
|
-
var s = el("p", "status");
|
|
644
|
-
if (state.failed[key]) {
|
|
645
|
-
s.className = "status failed";
|
|
646
|
-
s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
|
|
647
|
-
} else if (state.inflight[key]) {
|
|
648
|
-
s.textContent = "Saving…";
|
|
649
|
-
} else if (missing.length) {
|
|
650
|
-
s.className = "status missing";
|
|
651
|
-
s.textContent = "Still needs: " + missing.map(function (q) { return q.short || q.text; }).join("; ") +
|
|
652
|
-
(savedAt ? ". Saved " + timeOf(savedAt) : "");
|
|
653
|
-
} else if (savedAt) {
|
|
654
|
-
s.className = "status saved";
|
|
655
|
-
s.textContent = "Saved " + timeOf(savedAt);
|
|
656
|
-
}
|
|
657
|
-
return s;
|
|
658
|
-
}
|
|
659
|
-
function itemCard(item, off) {
|
|
660
|
-
var done = itemDone(item.id);
|
|
661
|
-
var card = el("div", "card" + (done ? " done" : ""));
|
|
662
|
-
card.appendChild(el("div", "id", item.id));
|
|
663
|
-
if (item.title) card.appendChild(el("h3", "", item.title));
|
|
664
|
-
card.appendChild(el("p", "", item.body));
|
|
665
|
-
if (item.code) card.appendChild(el("pre", "", item.code));
|
|
666
|
-
if (item.context) card.appendChild(el("p", "muted", item.context));
|
|
667
|
-
var a = answersOf(item.id);
|
|
668
|
-
DATA.questions.forEach(function (q) {
|
|
669
|
-
card.appendChild(el("p", "q", q.text));
|
|
670
|
-
card.appendChild(options(q.options, a[q.id], function (v) { answer(item.id, q.id, v); }, off));
|
|
671
|
-
});
|
|
672
|
-
var saved = state.answers[item.id] && state.answers[item.id].savedAt;
|
|
673
|
-
card.appendChild(statusLine(item.id, missingFor(item.id), saved));
|
|
674
|
-
return card;
|
|
675
|
-
}
|
|
676
|
-
function groupBlock(g, off, onlyOpen) {
|
|
677
|
-
var sec = el("section", "group");
|
|
678
|
-
var head = el("header");
|
|
679
|
-
head.appendChild(el("h2", "", g.label));
|
|
680
|
-
sec.appendChild(head);
|
|
681
|
-
var fam = el("div", "card" + (familiarity(g.id) !== undefined ? " done" : ""));
|
|
682
|
-
fam.appendChild(el("p", "q", DATA.familiarity_question.replace("{group}", g.label)));
|
|
683
|
-
fam.appendChild(options(DATA.familiarity, familiarity(g.id), function (v) { setFamiliarity(g.id, v); }, off));
|
|
684
|
-
var gs = state.groups[g.id];
|
|
685
|
-
fam.appendChild(statusLine("group:" + g.id,
|
|
686
|
-
familiarity(g.id) === undefined ? [{ text: "an answer" }] : [], gs && gs.savedAt));
|
|
687
|
-
sec.appendChild(fam);
|
|
688
|
-
groupItems(g.id).forEach(function (i) {
|
|
689
|
-
if (onlyOpen && itemDone(i.id)) return;
|
|
690
|
-
sec.appendChild(itemCard(i, off));
|
|
691
|
-
});
|
|
692
|
-
return sec;
|
|
693
|
-
}
|
|
694
|
-
function render() {
|
|
695
|
-
var off = !canWrite();
|
|
696
|
-
var done = DATA.items.filter(function (i) { return itemDone(i.id); }).length;
|
|
697
|
-
var total = DATA.items.length;
|
|
698
|
-
app.textContent = "";
|
|
699
|
-
app.appendChild(el("h1", "", DATA.title));
|
|
700
|
-
if (DATA.intro) app.appendChild(el("p", "muted", DATA.intro));
|
|
701
|
-
if (state.store === "none") {
|
|
702
|
-
app.appendChild(el("div", "banner",
|
|
703
|
-
"Answers cannot be saved in this view, so it is read-only. Open the page from the claude.ai " +
|
|
704
|
-
"artifact link while signed in with access to answer."));
|
|
705
|
-
} else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
|
|
706
|
-
app.appendChild(el("p", "muted", "Loading saved answers…"));
|
|
707
|
-
}
|
|
708
|
-
app.appendChild(el("p", "", done + " of " + total + " items complete"));
|
|
709
|
-
var bar = el("div", "bar"), fill = el("div");
|
|
710
|
-
fill.style.width = (total ? (100 * done / total) : 0) + "%";
|
|
711
|
-
bar.appendChild(fill);
|
|
712
|
-
app.appendChild(bar);
|
|
713
|
-
|
|
714
|
-
var filters = el("div", "filters");
|
|
715
|
-
[["all", "All items"], ["next", "Next unanswered"]].forEach(function (f) {
|
|
716
|
-
var b = el("button", "", f[1]);
|
|
717
|
-
b.type = "button";
|
|
718
|
-
b.setAttribute("aria-pressed", String(state.filter === f[0]));
|
|
719
|
-
b.addEventListener("click", function () {
|
|
720
|
-
state.filter = f[0];
|
|
721
|
-
try { localStorage.setItem("label-filter", f[0]); } catch (e) {}
|
|
722
|
-
render();
|
|
723
|
-
});
|
|
724
|
-
filters.appendChild(b);
|
|
725
|
-
});
|
|
726
|
-
app.appendChild(filters);
|
|
727
|
-
|
|
728
|
-
var onlyOpen = state.filter === "next";
|
|
729
|
-
if (onlyOpen) {
|
|
730
|
-
var rest = DATA.items.filter(function (i) { return !itemDone(i.id); });
|
|
731
|
-
var open = DATA.groups.filter(function (g) { return familiarity(g.id) === undefined; });
|
|
732
|
-
var note = el("div", "left");
|
|
733
|
-
if (!rest.length && !open.length) {
|
|
734
|
-
note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
|
|
735
|
-
} else {
|
|
736
|
-
note.appendChild(el("p", "", "Left to do:"));
|
|
737
|
-
var ul = el("ul");
|
|
738
|
-
open.forEach(function (g) { ul.appendChild(el("li", "", g.label + ": how well you know it")); });
|
|
739
|
-
rest.forEach(function (i) {
|
|
740
|
-
ul.appendChild(el("li", "", i.id + " needs " +
|
|
741
|
-
missingFor(i.id).map(function (q) { return q.short || q.text; }).join("; ")));
|
|
742
|
-
});
|
|
743
|
-
note.appendChild(ul);
|
|
744
|
-
}
|
|
745
|
-
app.appendChild(note);
|
|
746
|
-
}
|
|
747
|
-
DATA.groups.forEach(function (g) {
|
|
748
|
-
if (onlyOpen && groupDone(g.id)) return;
|
|
749
|
-
app.appendChild(groupBlock(g, off, onlyOpen));
|
|
750
|
-
});
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// ── the store: subscribe once per collection; render without it, light up when it arrives ──
|
|
754
|
-
function take(target, loadedKey) {
|
|
755
|
-
return function (snap) {
|
|
756
|
-
var next = {};
|
|
757
|
-
snap.docs.forEach(function (d) { next[d.id] = d.data(); });
|
|
758
|
-
state[target] = next;
|
|
759
|
-
state.loaded[loadedKey] = true;
|
|
760
|
-
render();
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
function broken() { state.store = "none"; render(); }
|
|
764
|
-
|
|
765
|
-
render();
|
|
766
|
-
if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }
|
|
767
|
-
window.claude.use("db").then(function (store) {
|
|
768
|
-
if (!store) { broken(); return; }
|
|
769
|
-
db = store;
|
|
770
|
-
state.store = "ok";
|
|
771
|
-
db.collection("answers").onSnapshot(take("answers", "answers"), broken);
|
|
772
|
-
db.collection("groups").onSnapshot(take("groups", "groups"), broken);
|
|
773
|
-
}, broken);
|
|
774
|
-
})();
|
|
775
|
-
</script>
|
|
776
|
-
"""
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def main(argv):
|
|
780
|
-
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
781
|
-
sub = p.add_subparsers(dest="cmd", required=True)
|
|
782
|
-
b = sub.add_parser("build", help="items.json -> page.html + key.json")
|
|
783
|
-
b.add_argument("items")
|
|
784
|
-
b.add_argument("out")
|
|
785
|
-
sub.add_parser("schema", help="print the declared items.json schema as JSON")
|
|
786
|
-
e = sub.add_parser("export", help="a dump of the store -> <study>-labels-<date>.json")
|
|
787
|
-
e.add_argument("dump")
|
|
788
|
-
e.add_argument("key")
|
|
789
|
-
e.add_argument("out")
|
|
790
|
-
e.add_argument("--date", default=date.today().isoformat())
|
|
791
|
-
args = p.parse_args(argv)
|
|
792
|
-
if args.cmd == "schema":
|
|
793
|
-
print(json.dumps(CONFIG_SCHEMA, indent=2))
|
|
794
|
-
elif args.cmd == "build":
|
|
795
|
-
build(args.items, args.out)
|
|
796
|
-
else:
|
|
797
|
-
if not re.match(r"^\d{4}-\d{2}-\d{2}$", args.date):
|
|
798
|
-
fail("--date must be YYYY-MM-DD")
|
|
799
|
-
export(args.dump, args.key, args.out, args.date)
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
if __name__ == "__main__":
|
|
803
|
-
main(sys.argv[1:])
|