- Quantum Studio: `#course` in the page's address opens the Course pane, and `#course=<lesson>` opens it at that lesson; it goes with `#qasm=…`. A test opens `/#course=bell` and finds the pane open at the Bell lesson, with no console error. Both READMEs say so. - The generated llms.txt (and llms-full.txt) list the quantum IDE, the interference course and WAI's home beside the console and Quantum Studio, and say the Studio's course opens from `#course`. The site's sync no longer needs to add them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
281 lines
16 KiB
JavaScript
281 lines
16 KiB
JavaScript
// The circuits course in the Studio: the lessons, each with a short text and
|
|
// an exercise the engine checks (engine::course, the same course and the same
|
|
// check as `wai-quantum learn`). Check runs the program in the editor against
|
|
// the exercise and gives the engine's verdict for each case and each rule;
|
|
// What went wrong? says, in the reader's language, what the reader's
|
|
// diagnostics, the verdict and the critique found; Hint gives the lesson's
|
|
// hints one at a time with the engine's pointer toward where to look. The
|
|
// assistant, when a model is connected, is asked for one hint and told not to
|
|
// write the program; any program in its answer is held back. None of these
|
|
// shows the solution.
|
|
//
|
|
// Progress (which lessons passed, how many checks and hints) is kept only in
|
|
// this browser, and says so.
|
|
import { el } from './dom.js';
|
|
import { t, locale, machineAssisted } from './i18n.js';
|
|
import { markdown, inline } from './md.js';
|
|
import { levelBadge } from './verdict-view.js';
|
|
|
|
const ITEM = 'wai.quantum-studio.course';
|
|
const OPEN = 'wai.quantum-studio.course-open';
|
|
const SCHEMA = 'wai.quantum.learn-progress/1';
|
|
|
|
// The reader's progress, kept in this browser alone.
|
|
export function progressStore() {
|
|
const fresh = () => ({ schema: SCHEMA, kept: 'only in this browser; nothing is sent anywhere', lessons: {}, current: null });
|
|
let p = fresh();
|
|
try {
|
|
const s = JSON.parse(localStorage.getItem(ITEM) || 'null');
|
|
if (s && s.schema === SCHEMA && s.lessons && typeof s.lessons === 'object') p = { ...fresh(), ...s };
|
|
} catch (e) {}
|
|
const save = () => { try { localStorage.setItem(ITEM, JSON.stringify(p)); } catch (e) {} };
|
|
return {
|
|
get: () => p,
|
|
lesson(id) { p.lessons[id] ??= { passed: false, checks: 0, hints: 0 }; return p.lessons[id]; },
|
|
passed: () => Object.values(p.lessons).filter((l) => l.passed).length,
|
|
save,
|
|
reset() { p = fresh(); save(); },
|
|
};
|
|
}
|
|
|
|
// Code blocks out of a model's answer: a hint in words only.
|
|
const prose = (text) => String(text || '').replace(/```[\s\S]*?(```|$)/g, '').trim();
|
|
|
|
export function coursePane({ engine, program, load, ask, status }) {
|
|
const store = progressStore();
|
|
let course = null;
|
|
let lesson = null;
|
|
let last = null; // {qasm, check}
|
|
const loc = locale();
|
|
|
|
const button = el('button', { class: 'qs-btn', id: 'qs-course-open', type: 'button', 'aria-expanded': 'false', 'aria-controls': 'qs-course', text: t('course.open'), title: t('course.open_title') });
|
|
const list = el('nav', { class: 'qs-course-list', id: 'qs-course-list', 'aria-label': t('course.lessons') });
|
|
const title = el('h3', { class: 'qs-course-title', id: 'qs-course-title' });
|
|
const where = el('p', { class: 'qs-note', id: 'qs-course-where' });
|
|
const text = el('div', { class: 'qs-course-text', id: 'qs-course-text' });
|
|
const task = el('p', { class: 'qs-course-task', id: 'qs-course-task' });
|
|
const must = el('ul', { class: 'qs-course-must', id: 'qs-course-must' });
|
|
const passedBadge = el('span', { class: 'qs-badge', id: 'qs-course-passed', text: t('course.not_checked') });
|
|
const statement = el('span', { class: 'qs-why', id: 'qs-course-statement' });
|
|
const cases = el('ul', { class: 'qs-course-cases', id: 'qs-course-cases' });
|
|
const rules = el('ul', { class: 'qs-course-rules', id: 'qs-course-rules' });
|
|
const estimate = el('p', { class: 'qs-note', id: 'qs-course-estimate', hidden: true });
|
|
const wrong = el('div', { class: 'qs-course-wrong', id: 'qs-course-wrong', hidden: true, role: 'region', 'aria-label': t('course.why') });
|
|
const hints = el('ol', { class: 'qs-course-hints', id: 'qs-course-hints' });
|
|
const pointer = el('p', { class: 'qs-note', id: 'qs-course-pointer', hidden: true });
|
|
const asked = el('div', { class: 'qs-course-asked', id: 'qs-course-asked', hidden: true });
|
|
const starterBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-starter', type: 'button', text: t('course.starter'), title: t('course.starter_title') });
|
|
const checkBtn = el('button', { class: 'qs-btn qs-small qs-primary', id: 'qs-course-check', type: 'button', text: t('course.check'), title: t('course.check_title') });
|
|
const hintBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-hint', type: 'button', text: t('course.hint') });
|
|
const whyBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-why', type: 'button', text: t('course.why') });
|
|
const askBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-ask', type: 'button', text: t('course.ask'), title: t('course.ask_title') });
|
|
const prev = el('button', { class: 'qs-btn qs-small', id: 'qs-course-prev', type: 'button', text: t('course.previous') });
|
|
const next = el('button', { class: 'qs-btn qs-small', id: 'qs-course-next', type: 'button', text: t('course.next') });
|
|
const progress = el('span', { class: 'qs-note', id: 'qs-course-progress' });
|
|
const reset = el('button', { class: 'qs-btn qs-small', id: 'qs-course-reset', type: 'button', text: t('course.reset') });
|
|
const companion = el('p', { class: 'qs-note', id: 'qs-course-companion' });
|
|
const translation = machineAssisted() ? el('p', { class: 'qs-note qs-course-translation', id: 'qs-course-translation', text: t('course.machine_assisted') }) : null;
|
|
const body = el('article', { class: 'qs-course-lesson', id: 'qs-course-lesson', 'aria-labelledby': 'qs-course-title' },
|
|
where, title, translation, text,
|
|
el('div', { class: 'qs-course-exercise' },
|
|
el('div', { class: 'qs-k', text: t('course.exercise') }), task, must,
|
|
el('div', { class: 'qs-actions' }, starterBtn, checkBtn, hintBtn, whyBtn, askBtn),
|
|
el('div', { class: 'qs-course-result', id: 'qs-course-result', 'aria-live': 'polite' }, el('p', {}, passedBadge, ' ', statement), cases, rules, estimate),
|
|
hints, pointer, wrong, asked),
|
|
el('div', { class: 'qs-actions qs-course-nav' }, prev, next));
|
|
const node = el('section', { class: 'qs-pane qs-course', id: 'qs-course', hidden: true, 'aria-labelledby': 'qs-course-h' },
|
|
el('div', { class: 'qs-head' }, el('h2', { id: 'qs-course-h', text: t('course.title') }), el('span', { class: 'qs-grow' }), progress, reset),
|
|
el('div', { class: 'qs-course-body' }, list, body),
|
|
companion);
|
|
|
|
const say = (m, cls) => status?.(m, cls);
|
|
const showProgress = () => {
|
|
progress.textContent = t('course.progress', { passed: store.passed(), of: course?.lessons ?? '?' }) + ' · ' + t('course.kept');
|
|
for (const b of list.querySelectorAll('[data-lesson]')) {
|
|
const done = !!store.get().lessons[b.dataset.lesson]?.passed;
|
|
b.classList.toggle('done', done);
|
|
b.querySelector('.qs-course-mark').textContent = done ? '✓' : '·';
|
|
b.setAttribute('aria-current', String(b.dataset.lesson === lesson?.id));
|
|
}
|
|
};
|
|
|
|
function renderList() {
|
|
list.replaceChildren(...course.units.map((u) => el('div', { class: 'qs-course-unit' },
|
|
el('div', { class: 'qs-k', text: u.title }),
|
|
el('ol', { start: String(u.lessons[0]?.number ?? 1) }, ...u.lessons.map((l) => el('li', {},
|
|
el('button', { class: 'qs-course-item', type: 'button', 'data-lesson': l.id, onclick: () => show(l.id) },
|
|
el('span', { class: 'qs-course-mark', 'aria-hidden': 'true', text: '·' }), ' ', l.title)))))));
|
|
const k = course.companions?.[0];
|
|
companion.replaceChildren(...(k ? [t('course.companion') + ' ', el('a', { href: k.url, target: '_blank', rel: 'noopener', text: k.title }), ` · ${k.summary}`] : []));
|
|
}
|
|
|
|
function clearResult() {
|
|
last = null;
|
|
passedBadge.textContent = t('course.not_checked'); passedBadge.className = 'qs-badge';
|
|
statement.textContent = '';
|
|
cases.replaceChildren(); rules.replaceChildren();
|
|
estimate.hidden = true; wrong.hidden = true; wrong.replaceChildren();
|
|
pointer.hidden = true; asked.hidden = true; asked.replaceChildren();
|
|
}
|
|
|
|
async function show(id) {
|
|
try {
|
|
lesson = await engine.courseLesson(id, loc);
|
|
} catch (e) { say(e.message, 'bad'); return; }
|
|
store.get().current = id; store.save();
|
|
where.textContent = t('course.where', { number: lesson.number, of: lesson.of, unit: lesson.unit_title });
|
|
title.textContent = lesson.title;
|
|
text.replaceChildren(...markdown(lesson.text));
|
|
task.replaceChildren(...inline(lesson.task));
|
|
must.replaceChildren(...lesson.requirements.map((r) => el('li', {}, ...inline(r))));
|
|
prev.disabled = !lesson.previous; next.disabled = !lesson.next;
|
|
clearResult();
|
|
// The hints already read stay read.
|
|
const seen = Math.min(store.lesson(id).hints || 0, lesson.hints.length);
|
|
hints.replaceChildren(...lesson.hints.slice(0, seen).map((h, i) => el('li', {}, el('span', { class: 'qs-k', text: t('course.hint_of', { level: i + 1, of: lesson.hints.length }) }), ' ', ...inline(h))));
|
|
hintBtn.textContent = seen >= lesson.hints.length ? t('course.no_more_hints') : t('course.hint');
|
|
showProgress();
|
|
}
|
|
|
|
async function check() {
|
|
if (!lesson) return null;
|
|
const qasm = program();
|
|
checkBtn.disabled = true;
|
|
try {
|
|
const c = await engine.courseCheck(lesson.id, qasm, loc);
|
|
last = { qasm, check: c };
|
|
const rec = store.lesson(lesson.id);
|
|
rec.checks += 1;
|
|
if (c.passed && !rec.passed) { rec.passed = true; rec.passed_at = Date.now(); }
|
|
store.save();
|
|
passedBadge.textContent = c.passed ? t('course.passed') : t('course.not_yet');
|
|
passedBadge.className = 'qs-badge ' + (c.passed ? 'ok' : 'bad');
|
|
statement.textContent = c.statement;
|
|
cases.replaceChildren(...c.cases.map((k) => el('li', { class: k.passed ? 'ok' : 'bad' },
|
|
k.name ? el('span', { class: 'qs-course-case', text: `${k.name}: ` }) : null, levelBadge(k.verdict), el('span', { class: 'qs-why', text: ` ${k.statement ?? ''}` }))));
|
|
rules.replaceChildren(...c.constraints.map((r) => el('li', { class: r.holds ? 'ok' : 'bad', 'data-rule': r.rule }, `${r.holds ? '✓' : '✗'} `, ...inline(r.text))));
|
|
estimate.hidden = !c.estimate?.figures;
|
|
estimate.textContent = c.estimate?.figures ?? '';
|
|
if (!wrong.hidden) showWrong(c);
|
|
pointer.hidden = true;
|
|
showProgress();
|
|
say(c.passed ? t('course.passed_status', { title: lesson.title }) : t('course.not_yet_status'), c.passed ? 'ok' : 'bad');
|
|
return c;
|
|
} catch (e) { say(e.message, 'bad'); return null; } finally { checkBtn.disabled = false; }
|
|
}
|
|
|
|
// The last check, if it was of the program as it is now; else a new one.
|
|
const current = async () => (last && last.qasm === program() ? last.check : check());
|
|
|
|
function showWrong(c) {
|
|
wrong.hidden = false;
|
|
const items = c.what_went_wrong.map((w) => el('li', { class: `qs-course-w-${w.kind}` }, ...inline(w.text),
|
|
w.engine ? el('span', { class: 'qs-course-engine' }, el('span', { class: 'qs-k', text: ` ${t('course.engine_says')} ` }), w.engine) : null));
|
|
wrong.replaceChildren(el('div', { class: 'qs-k', text: t('course.why') }),
|
|
items.length ? el('ul', {}, ...items) : el('p', { class: 'qs-note', text: t('course.nothing_wrong') }),
|
|
c.pointer && !c.passed ? el('p', { class: 'qs-note' }, el('span', { class: 'qs-k', text: t('course.where_to_look') + ' ' }), c.pointer) : null);
|
|
}
|
|
|
|
async function why() {
|
|
const c = await current();
|
|
if (c) showWrong(c);
|
|
}
|
|
|
|
async function hint() {
|
|
if (!lesson) return;
|
|
const rec = store.lesson(lesson.id);
|
|
const level = Math.min((rec.hints || 0) + 1, lesson.hints.length);
|
|
try {
|
|
const h = await engine.courseHint(lesson.id, program(), level, loc);
|
|
if (h.level > (rec.hints || 0)) {
|
|
rec.hints = h.level; store.save();
|
|
hints.append(el('li', {}, el('span', { class: 'qs-k', text: h.label }), ' ', ...inline(h.hint)));
|
|
}
|
|
pointer.hidden = !h.pointer || h.passed;
|
|
pointer.textContent = h.pointer ? `${t('course.where_to_look')} ${h.pointer}` : '';
|
|
hintBtn.textContent = h.more ? t('course.hint') : t('course.no_more_hints');
|
|
} catch (e) { say(e.message, 'bad'); }
|
|
}
|
|
|
|
async function askModel() {
|
|
if (!lesson) return;
|
|
const c = await current();
|
|
if (!c) return;
|
|
asked.hidden = false;
|
|
if (!ask.available()) { asked.replaceChildren(el('p', { class: 'qs-note', text: t('course.ask_needs_model') })); return; }
|
|
asked.replaceChildren(el('p', { class: 'qs-note', text: t('course.asking') }));
|
|
const findings = c.what_went_wrong.map((w) => `- ${w.text}${w.engine ? ` (${w.engine})` : ''}`).join('\n');
|
|
const prompt = [
|
|
`I am doing the exercise "${lesson.title}": ${lesson.task}`,
|
|
`It must: ${lesson.requirements.join('; ')}.`,
|
|
`The engine checked my program: ${c.statement}`,
|
|
findings ? `What went wrong, as the engine found it:\n${findings}` : '',
|
|
'Give me one hint toward the next step, in a sentence or two. Do not write the program, any line of it, or the answer; point me at what to think about.',
|
|
loc === 'zh-CN' ? 'Answer in Simplified Chinese.' : '',
|
|
].filter(Boolean).join('\n\n');
|
|
try {
|
|
const a = await ask.hint(prompt);
|
|
// A program in the answer that passes the exercise would be the solution: it is held back.
|
|
let held = false;
|
|
for (const p of a.proposals || []) {
|
|
const r = await engine.courseCheck(lesson.id, p.qasm, loc).catch(() => null);
|
|
if (r?.passed) held = true;
|
|
}
|
|
const words = prose(a.text);
|
|
asked.replaceChildren(el('div', { class: 'qs-k', text: t('course.assistant_says') }),
|
|
words ? el('p', {}, ...inline(words)) : null,
|
|
held || words !== String(a.text || '').trim() ? el('p', { class: 'qs-note', id: 'qs-course-held', text: held ? t('course.held_solution') : t('course.held_code') }) : null);
|
|
} catch (e) { asked.replaceChildren(el('p', { class: 'qs-note', text: t('course.ask_failed', { error: e.message }) })); }
|
|
}
|
|
|
|
starterBtn.addEventListener('click', async () => {
|
|
if (!lesson) return;
|
|
if (await load(lesson.starter, lesson)) { clearResult(); say(t('course.starter_loaded', { title: lesson.title }), 'ok'); }
|
|
});
|
|
checkBtn.addEventListener('click', check);
|
|
hintBtn.addEventListener('click', hint);
|
|
whyBtn.addEventListener('click', why);
|
|
askBtn.addEventListener('click', askModel);
|
|
prev.addEventListener('click', () => lesson?.previous && show(lesson.previous));
|
|
next.addEventListener('click', () => lesson?.next && show(lesson.next));
|
|
// Two clicks, so that one slip does not clear it.
|
|
let armed = null;
|
|
reset.addEventListener('click', () => {
|
|
if (!armed) {
|
|
reset.textContent = t('course.reset_confirm');
|
|
armed = setTimeout(() => { armed = null; reset.textContent = t('course.reset'); }, 5000);
|
|
return;
|
|
}
|
|
clearTimeout(armed); armed = null;
|
|
reset.textContent = t('course.reset');
|
|
store.reset();
|
|
if (lesson) show(lesson.id);
|
|
say(t('course.reset_done'), 'ok');
|
|
});
|
|
|
|
async function open(on = node.hidden) {
|
|
node.hidden = !on;
|
|
button.setAttribute('aria-expanded', String(on));
|
|
try { localStorage.setItem(OPEN, on ? '1' : '0'); } catch (e) {}
|
|
if (!on || course) return;
|
|
try {
|
|
course = await engine.course(loc);
|
|
renderList();
|
|
const p = store.get();
|
|
const first = course.units.flatMap((u) => u.lessons).find((l) => !p.lessons[l.id]?.passed) ?? course.units[0].lessons[0];
|
|
await show(p.current && course.units.some((u) => u.lessons.some((l) => l.id === p.current)) ? p.current : first.id);
|
|
} catch (e) { say(e.message, 'bad'); }
|
|
}
|
|
button.addEventListener('click', () => open());
|
|
// `#course` in the page's address opens the pane (a link from the course page), and
|
|
// `#course=<lesson>` opens it at that lesson; otherwise it reopens as it was left.
|
|
const linked = new URLSearchParams(location.hash.slice(1));
|
|
let reopen = linked.has('course');
|
|
if (!reopen) try { reopen = localStorage.getItem(OPEN) === '1'; } catch (e) {}
|
|
if (reopen) open(true).then(() => {
|
|
const id = linked.get('course');
|
|
if (id && course?.units.some((u) => u.lessons.some((l) => l.id === id))) return show(id);
|
|
});
|
|
|
|
return { node, button, open, show, check, store, current: () => lesson };
|
|
}
|