cert-coach-web 0.1.0 → 0.1.2
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/AGENTS.md +4 -2
- package/README.md +15 -4
- package/engine/core/cert-core.mjs +6 -1
- package/engine/extractor.js +1 -1
- package/engine/scripts/export_private.ps1 +8 -6
- package/package.json +4 -3
- package/web/public/app.js +77 -1013
- package/web/public/index.html +1 -1
- package/web/public/spa-api.js +33 -0
- package/web/public/spa-chat.js +181 -0
- package/web/public/spa-dom.js +89 -0
- package/web/public/spa-logic.js +20 -0
- package/web/public/spa-mermaid.js +54 -0
- package/web/public/spa-question.js +206 -0
- package/web/public/spa-render.js +107 -0
- package/web/public/spa-store.js +100 -0
- package/web/public/spa-ui.js +62 -0
- package/web/public/spa-views.js +279 -0
- package/web/public/styles.css +1 -0
- package/web/server/chat.mjs +74 -4
- package/web/server/http.mjs +7 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// DOM markdown renderer, extracted from app.js so it can be unit-tested in
|
|
2
|
+
// jsdom. `doc` is injected (rather than using the global) so the same code runs
|
|
3
|
+
// in the browser (documents) and in tests (jsdom window.document).
|
|
4
|
+
import { safeHttpUrl } from "./spa-logic.js";
|
|
5
|
+
|
|
6
|
+
const INLINE = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\(https?:\/\/[^)\s]+\))/g;
|
|
7
|
+
|
|
8
|
+
/** Inline markdown (code, bold, http links) -> DocumentFragment. */
|
|
9
|
+
export function inlineNodes(doc, text) {
|
|
10
|
+
const frag = doc.createDocumentFragment();
|
|
11
|
+
let last = 0;
|
|
12
|
+
for (const m of text.matchAll(INLINE)) {
|
|
13
|
+
if (m.index > last) frag.append(doc.createTextNode(text.slice(last, m.index)));
|
|
14
|
+
const tok = m[0];
|
|
15
|
+
if (tok.startsWith("`")) {
|
|
16
|
+
const code = doc.createElement("code");
|
|
17
|
+
code.textContent = tok.slice(1, -1);
|
|
18
|
+
frag.append(code);
|
|
19
|
+
} else if (tok.startsWith("**")) {
|
|
20
|
+
const b = doc.createElement("strong");
|
|
21
|
+
b.textContent = tok.slice(2, -2);
|
|
22
|
+
frag.append(b);
|
|
23
|
+
} else {
|
|
24
|
+
const mm = tok.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
|
|
25
|
+
const a = doc.createElement("a");
|
|
26
|
+
a.textContent = mm?.[1] || tok;
|
|
27
|
+
a.href = safeHttpUrl(mm?.[2] || "");
|
|
28
|
+
a.target = "_blank";
|
|
29
|
+
a.rel = "noreferrer";
|
|
30
|
+
frag.append(a);
|
|
31
|
+
}
|
|
32
|
+
last = m.index + tok.length;
|
|
33
|
+
}
|
|
34
|
+
if (last < text.length) frag.append(doc.createTextNode(text.slice(last)));
|
|
35
|
+
return frag;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Block markdown (fenced code, headings, lists, quotes, paragraphs) -> DocumentFragment. */
|
|
39
|
+
export function markdownFragment(doc, text) {
|
|
40
|
+
const frag = doc.createDocumentFragment();
|
|
41
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
42
|
+
let i = 0;
|
|
43
|
+
let para = [];
|
|
44
|
+
const flush = () => {
|
|
45
|
+
if (!para.length) return;
|
|
46
|
+
const p = doc.createElement("p");
|
|
47
|
+
p.append(inlineNodes(doc, para.join(" ")));
|
|
48
|
+
frag.append(p);
|
|
49
|
+
para = [];
|
|
50
|
+
};
|
|
51
|
+
while (i < lines.length) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (/^```/.test(line)) {
|
|
54
|
+
flush();
|
|
55
|
+
const lang = line.slice(3).trim();
|
|
56
|
+
i++;
|
|
57
|
+
const code = [];
|
|
58
|
+
while (i < lines.length && !/^```/.test(lines[i])) code.push(lines[i++]);
|
|
59
|
+
if (i < lines.length) i++;
|
|
60
|
+
const pre = doc.createElement("pre");
|
|
61
|
+
if (lang === "mermaid") pre.className = "diagram";
|
|
62
|
+
const c = doc.createElement("code");
|
|
63
|
+
c.textContent = code.join("\n");
|
|
64
|
+
pre.append(c);
|
|
65
|
+
frag.append(pre);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
69
|
+
if (h) {
|
|
70
|
+
flush();
|
|
71
|
+
const elh = doc.createElement("h" + h[1].length);
|
|
72
|
+
elh.append(inlineNodes(doc, h[2]));
|
|
73
|
+
frag.append(elh);
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
78
|
+
flush();
|
|
79
|
+
const ul = doc.createElement("ul");
|
|
80
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
81
|
+
const li = doc.createElement("li");
|
|
82
|
+
li.append(inlineNodes(doc, lines[i].replace(/^\s*[-*]\s+/, "")));
|
|
83
|
+
ul.append(li);
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
frag.append(ul);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (/^>\s?/.test(line)) {
|
|
90
|
+
flush();
|
|
91
|
+
const quote = doc.createElement("blockquote");
|
|
92
|
+
quote.append(inlineNodes(doc, line.replace(/^>\s?/, "")));
|
|
93
|
+
frag.append(quote);
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!line.trim()) {
|
|
98
|
+
flush();
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
para.push(line);
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
flush();
|
|
106
|
+
return frag;
|
|
107
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console session store: the mutable bits that used to be module-level `let`s.
|
|
3
|
+
*
|
|
4
|
+
* Everything lives on one exported object so other modules can mutate fields
|
|
5
|
+
* (`store.state = s`). A module cannot reassign an imported binding, so plain
|
|
6
|
+
* named exports would not work here.
|
|
7
|
+
*
|
|
8
|
+
* The small pure helpers at the bottom are unit-tested in tests/spa-store.test.mjs
|
|
9
|
+
* because they carry the fiddly rules (multi-select toggling, timer labels,
|
|
10
|
+
* which card a phase maps to).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const store = {
|
|
14
|
+
/** Latest state snapshot from the server. Null until bootstrap. */
|
|
15
|
+
state: null,
|
|
16
|
+
/** Currently selected option values. */
|
|
17
|
+
selected: new Set(),
|
|
18
|
+
/** 1 = guess, 2 = unsure, 3 = confident. */
|
|
19
|
+
confidence: 2,
|
|
20
|
+
/** Epoch ms the current question was first shown. */
|
|
21
|
+
startedAt: 0,
|
|
22
|
+
/** `${qid}:${rev}` of the question the timer belongs to. */
|
|
23
|
+
questionKey: "",
|
|
24
|
+
timerHandle: null,
|
|
25
|
+
|
|
26
|
+
eventSource: null,
|
|
27
|
+
theoryNodes: null,
|
|
28
|
+
activeTheoryNode: "",
|
|
29
|
+
activeView: "study",
|
|
30
|
+
activeLayout: "triptych",
|
|
31
|
+
|
|
32
|
+
chat: {
|
|
33
|
+
enabled: false,
|
|
34
|
+
reason: "",
|
|
35
|
+
busy: false,
|
|
36
|
+
/** { div, text } while an assistant answer streams. */
|
|
37
|
+
stream: null,
|
|
38
|
+
/** Echo suppression: the message we already rendered optimistically. */
|
|
39
|
+
pendingUser: "",
|
|
40
|
+
},
|
|
41
|
+
toolCards: new Map(),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** New selection set for a single- or multi-select question. */
|
|
45
|
+
export function toggleSelection(selected, value, multi) {
|
|
46
|
+
const next = new Set(selected || []);
|
|
47
|
+
if (!multi) return new Set([value]);
|
|
48
|
+
if (next.has(value)) next.delete(value);
|
|
49
|
+
else next.add(value);
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `m:ss` for a non-negative number of seconds. */
|
|
54
|
+
export function formatTimer(totalSeconds) {
|
|
55
|
+
const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
|
|
56
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Timer text for a question. A timed mock counts down to its deadline;
|
|
61
|
+
* otherwise the timer counts up from when the question appeared.
|
|
62
|
+
*/
|
|
63
|
+
export function timerLabel({ deadline = 0, startedAt = 0 }, now = Date.now()) {
|
|
64
|
+
if (deadline) return `${formatTimer((deadline - now) / 1000)} left`;
|
|
65
|
+
return formatTimer((now - startedAt) / 1000);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Stem length drives a CSS class so long question text gets a smaller font. */
|
|
69
|
+
export function stemClass(text) {
|
|
70
|
+
const len = String(text || "").length;
|
|
71
|
+
if (len > 420) return "stem-xlong";
|
|
72
|
+
if (len > 220) return "stem-long";
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Human-readable mock summary, built from the loop's mock payload. */
|
|
77
|
+
export function mockSummary(m = {}) {
|
|
78
|
+
const completed = m.completed ?? m.total ?? 0;
|
|
79
|
+
const requested = m.requested ?? m.total ?? 0;
|
|
80
|
+
const score = m.correct || 0;
|
|
81
|
+
return {
|
|
82
|
+
meta: `${score}/${completed} correct · ${completed}/${requested} completed`,
|
|
83
|
+
title: m.score != null ? `Mock score: ${Math.round(m.score * 100)}%` : "Mock complete",
|
|
84
|
+
note: `${m.timedOut ? "Time expired. " : ""}Requested ${m.requested ?? "?"} questions; completed ${completed}. Feedback is shown now that the run is over. progress.json was not modified.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Which card a server phase maps to (`loading` means nothing to show yet). */
|
|
89
|
+
export function phaseCard(phase) {
|
|
90
|
+
if (phase === "question" || phase === "submitted") return "question";
|
|
91
|
+
if (phase === "feedback") return "feedback";
|
|
92
|
+
if (phase === "teach") return "teach";
|
|
93
|
+
if (phase === "mock_summary") return "mock";
|
|
94
|
+
return "idle";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when the server says the current question takes multiple answers. */
|
|
98
|
+
export function isMultiSelect(state) {
|
|
99
|
+
return state?.q?.multi === true;
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaf UI helpers: connection dot, notice bar, card switching, phase pill,
|
|
3
|
+
* layout toggle, and the coach status log.
|
|
4
|
+
*
|
|
5
|
+
* No feature logic lives here, so every module can depend on these freely.
|
|
6
|
+
*/
|
|
7
|
+
import { el } from "./spa-dom.js";
|
|
8
|
+
import { store } from "./spa-store.js";
|
|
9
|
+
|
|
10
|
+
export function setConnection(kind) {
|
|
11
|
+
el.connection.classList.remove("live", "down");
|
|
12
|
+
if (kind === "live") el.connection.classList.add("live");
|
|
13
|
+
if (kind === "down") el.connection.classList.add("down");
|
|
14
|
+
el.connection.title = kind === "live" ? "live" : kind === "down" ? "reconnecting" : "connecting";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function showNotice(text) {
|
|
18
|
+
if (!text) {
|
|
19
|
+
el.notice.hidden = true;
|
|
20
|
+
el.notice.textContent = "";
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
el.notice.hidden = false;
|
|
24
|
+
el.notice.textContent = text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Show exactly one of the study cards. `null` shows none. */
|
|
28
|
+
export function only(card) {
|
|
29
|
+
for (const c of [el.loading, el.idle, el.question, el.feedback, el.teach, el.mock]) {
|
|
30
|
+
if (c) c.hidden = c !== card;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setPhase(phase) {
|
|
35
|
+
el.phasePill.textContent = phase || "idle";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setLayout(name) {
|
|
39
|
+
store.activeLayout = ["triptych", "command"].includes(name) ? name : "triptych";
|
|
40
|
+
el.app.dataset.layout = store.activeLayout;
|
|
41
|
+
for (const b of el.layoutButtons) b.classList.toggle("active", b.dataset.appLayout === store.activeLayout);
|
|
42
|
+
localStorage.setItem("certCoachLayout", store.activeLayout);
|
|
43
|
+
return store.activeLayout;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Prepend coach log lines (newest first) and cap the list at 80. */
|
|
47
|
+
export function appendStatus(lines) {
|
|
48
|
+
for (const item of lines || []) {
|
|
49
|
+
const div = document.createElement("div");
|
|
50
|
+
div.className = "status-line";
|
|
51
|
+
const t = item?.at ? new Date(item.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
|
|
52
|
+
const strong = document.createElement("strong");
|
|
53
|
+
strong.textContent = t ? `${t} ` : "";
|
|
54
|
+
const span = document.createElement("span");
|
|
55
|
+
span.textContent = item?.line || "";
|
|
56
|
+
div.append(strong, span);
|
|
57
|
+
el.statusLog.prepend(div);
|
|
58
|
+
}
|
|
59
|
+
while (el.statusLog.children.length > 80) el.statusLog.lastChild.remove();
|
|
60
|
+
// Newest just under the "Coach log" title; older entries stay below.
|
|
61
|
+
el.statusLog.parentElement.scrollTop = 0;
|
|
62
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The non-study views: Path (blueprint route), Dashboard (readiness), Theory
|
|
3
|
+
* (per-node reference) and Logs (study_log.md), plus the command-center strip.
|
|
4
|
+
*
|
|
5
|
+
* Every loader reads the same /api/status or /api/theory endpoints and never
|
|
6
|
+
* mutates progress.
|
|
7
|
+
*/
|
|
8
|
+
import { el } from "./spa-dom.js";
|
|
9
|
+
import { store } from "./spa-store.js";
|
|
10
|
+
import { api } from "./spa-api.js";
|
|
11
|
+
import { renderMarkdownInto } from "./spa-mermaid.js";
|
|
12
|
+
import { safeHttpUrl } from "./spa-logic.js";
|
|
13
|
+
|
|
14
|
+
function stat(k, v) {
|
|
15
|
+
const d = document.createElement("div");
|
|
16
|
+
d.className = "stat";
|
|
17
|
+
const kk = document.createElement("div");
|
|
18
|
+
kk.className = "k";
|
|
19
|
+
kk.textContent = k;
|
|
20
|
+
const vv = document.createElement("div");
|
|
21
|
+
vv.className = "v";
|
|
22
|
+
vv.textContent = String(v);
|
|
23
|
+
d.append(kk, vv);
|
|
24
|
+
return d;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Compact readiness/domains strip shown in the command-center layout. */
|
|
28
|
+
export async function updateCommandStrip() {
|
|
29
|
+
try {
|
|
30
|
+
const data = await api("/api/status");
|
|
31
|
+
const d = data.dashboard || {};
|
|
32
|
+
const p = data.plan || {};
|
|
33
|
+
const r = d.readiness || {};
|
|
34
|
+
const readiness = Math.round((r.value || 0) * 100);
|
|
35
|
+
const coverage = Math.round((r.coverage || 0) * 100);
|
|
36
|
+
el.cmdReadiness.textContent = `${readiness}%`;
|
|
37
|
+
el.cmdCoverage.textContent = `${coverage}%`;
|
|
38
|
+
el.cmdDue.textContent = String(d.due || 0);
|
|
39
|
+
const weak = (p.weakNodes || [])[0];
|
|
40
|
+
el.cmdWeak.textContent = weak?.domain || "—";
|
|
41
|
+
el.cmdWeakNote.textContent = weak ? `${weak.title} · ${Math.round((weak.accuracy || 0) * 100)}%` : "No attempts yet";
|
|
42
|
+
el.cmdReadinessBar.style.setProperty("--value", `${readiness}%`);
|
|
43
|
+
el.cmdCoverageBar.style.setProperty("--value", `${coverage}%`);
|
|
44
|
+
el.cmdDomains.replaceChildren();
|
|
45
|
+
for (const dom of p.domains || []) {
|
|
46
|
+
const row = document.createElement("div");
|
|
47
|
+
row.className = "domain-row";
|
|
48
|
+
const name = document.createElement("span");
|
|
49
|
+
name.textContent = dom.name;
|
|
50
|
+
const meter = document.createElement("div");
|
|
51
|
+
meter.className = "domain-meter";
|
|
52
|
+
const fill = document.createElement("i");
|
|
53
|
+
fill.style.setProperty("--value", `${Math.round((dom.accuracy || 0) * 100)}%`);
|
|
54
|
+
meter.append(fill);
|
|
55
|
+
const val = document.createElement("b");
|
|
56
|
+
val.textContent = `${Math.round((dom.accuracy || 0) * 100)}%`;
|
|
57
|
+
row.append(name, meter, val);
|
|
58
|
+
el.cmdDomains.append(row);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
/* command strip is optional */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function setView(name) {
|
|
66
|
+
store.activeView = name;
|
|
67
|
+
for (const b of document.querySelectorAll(".nav button, .side-nav button")) b.classList.toggle("active", b.dataset.tab === name);
|
|
68
|
+
el.studyView.hidden = name !== "study";
|
|
69
|
+
el.pathView.hidden = name !== "path";
|
|
70
|
+
el.dashboardView.hidden = name !== "dashboard";
|
|
71
|
+
el.theoryView.hidden = name !== "theory";
|
|
72
|
+
el.logsView.hidden = name !== "logs";
|
|
73
|
+
if (name === "path") void loadPath();
|
|
74
|
+
if (name === "dashboard") void loadDashboard();
|
|
75
|
+
if (name === "theory") void loadTheory();
|
|
76
|
+
if (name === "logs") void loadLogs();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loadPath() {
|
|
80
|
+
el.pathRoute.textContent = "Loading...";
|
|
81
|
+
el.pathDetail.textContent = "";
|
|
82
|
+
try {
|
|
83
|
+
const data = await api("/api/status");
|
|
84
|
+
const p = data.plan || {};
|
|
85
|
+
el.pathMeta.textContent = p.ready
|
|
86
|
+
? "target met"
|
|
87
|
+
: `probe ${Math.min(p.seenQuestions || 0, p.probeSize || 20)}/${p.probeSize || 20} · target ${Math.round((p.target?.readiness || 0.85) * 100)}% readiness / ${Math.round((p.target?.coverage || 0.8) * 100)}% coverage`;
|
|
88
|
+
el.pathRoute.replaceChildren();
|
|
89
|
+
const steps = document.createElement("div");
|
|
90
|
+
steps.className = "route-steps";
|
|
91
|
+
for (const step of p.steps || []) {
|
|
92
|
+
const d = document.createElement("div");
|
|
93
|
+
d.className = "route-step " + (step.state || "");
|
|
94
|
+
d.textContent = `${step.name} · ${step.state || ""}`;
|
|
95
|
+
steps.append(d);
|
|
96
|
+
}
|
|
97
|
+
el.pathRoute.append(steps);
|
|
98
|
+
const callout = document.createElement("div");
|
|
99
|
+
callout.className = "route-callout";
|
|
100
|
+
const phase = document.createElement("h3");
|
|
101
|
+
phase.textContent = p.ready ? "READY" : String(p.phase || "progress").toUpperCase();
|
|
102
|
+
const why = document.createElement("p");
|
|
103
|
+
why.textContent = p.why || "";
|
|
104
|
+
const next = document.createElement("p");
|
|
105
|
+
next.textContent = "Next: " + (p.next || "");
|
|
106
|
+
const criteria = document.createElement("p");
|
|
107
|
+
criteria.className = "muted";
|
|
108
|
+
criteria.textContent = p.criteria || "";
|
|
109
|
+
callout.append(phase, why, next, criteria);
|
|
110
|
+
el.pathRoute.append(callout);
|
|
111
|
+
|
|
112
|
+
el.pathDetail.replaceChildren();
|
|
113
|
+
for (const d of p.domains || []) {
|
|
114
|
+
const section = document.createElement("section");
|
|
115
|
+
section.className = "path-domain";
|
|
116
|
+
const h = document.createElement("h3");
|
|
117
|
+
h.textContent = `${d.name} · ${Math.round((d.weight_min || 0) * 100)}–${Math.round((d.weight_max || 0) * 100)}% of exam`;
|
|
118
|
+
const summary = document.createElement("p");
|
|
119
|
+
summary.className = "muted";
|
|
120
|
+
summary.textContent = `${d.seen}/${d.total} questions seen · ${d.accuracy == null ? "no accuracy yet" : `${Math.round(d.accuracy * 100)}% accuracy`} · coverage ${Math.round((d.coverage || 0) * 100)}%`;
|
|
121
|
+
section.append(h, summary);
|
|
122
|
+
if (d.skills?.length) {
|
|
123
|
+
const skills = document.createElement("ul");
|
|
124
|
+
skills.className = "path-skills";
|
|
125
|
+
for (const s of d.skills) { const li = document.createElement("li"); li.textContent = s; skills.append(li); }
|
|
126
|
+
section.append(skills);
|
|
127
|
+
}
|
|
128
|
+
const table = document.createElement("table");
|
|
129
|
+
table.className = "node-table";
|
|
130
|
+
const head = document.createElement("tr");
|
|
131
|
+
for (const label of ["Node / subdomain", "Status", "Questions", "Accuracy", "Box"]) {
|
|
132
|
+
const th = document.createElement("th"); th.textContent = label; head.append(th);
|
|
133
|
+
}
|
|
134
|
+
table.append(head);
|
|
135
|
+
for (const n of d.nodes || []) {
|
|
136
|
+
const tr = document.createElement("tr");
|
|
137
|
+
const title = document.createElement("td");
|
|
138
|
+
title.className = "path-node-title";
|
|
139
|
+
title.textContent = n.title;
|
|
140
|
+
const small = document.createElement("small");
|
|
141
|
+
small.textContent = n.summary || n.id;
|
|
142
|
+
title.append(small);
|
|
143
|
+
const status = document.createElement("td");
|
|
144
|
+
const tag = document.createElement("span");
|
|
145
|
+
tag.className = "status-tag " + (n.status || "unseen");
|
|
146
|
+
tag.textContent = n.status || "unseen";
|
|
147
|
+
status.append(tag);
|
|
148
|
+
const qs = document.createElement("td");
|
|
149
|
+
qs.className = "num";
|
|
150
|
+
qs.textContent = `${n.seen}/${n.total}`;
|
|
151
|
+
const acc = document.createElement("td");
|
|
152
|
+
acc.className = "num";
|
|
153
|
+
acc.textContent = n.accuracy == null ? "—" : `${Math.round(n.accuracy * 100)}%`;
|
|
154
|
+
const box = document.createElement("td");
|
|
155
|
+
box.className = "num";
|
|
156
|
+
box.textContent = String(n.box ?? 0);
|
|
157
|
+
tr.append(title, status, qs, acc, box);
|
|
158
|
+
table.append(tr);
|
|
159
|
+
}
|
|
160
|
+
section.append(table);
|
|
161
|
+
el.pathDetail.append(section);
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
el.pathRoute.textContent = e.message;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function loadDashboard() {
|
|
169
|
+
el.dashboardBody.textContent = "Loading...";
|
|
170
|
+
try {
|
|
171
|
+
const data = await api("/api/status");
|
|
172
|
+
const d = data.dashboard || {};
|
|
173
|
+
const r = d.readiness || {};
|
|
174
|
+
el.dashboardMeta.textContent = `${d.title || ""} · ${d.verified || 0}/${d.total || 0} verified`;
|
|
175
|
+
el.dashboardBody.replaceChildren();
|
|
176
|
+
const grid = document.createElement("div");
|
|
177
|
+
grid.className = "dash-grid";
|
|
178
|
+
grid.append(
|
|
179
|
+
stat("Readiness", `${((r.value || 0) * 100).toFixed(1)}%`),
|
|
180
|
+
stat("Coverage", `${((r.coverage || 0) * 100).toFixed(0)}%`),
|
|
181
|
+
stat("Due now", d.due || 0),
|
|
182
|
+
stat("Gaps / review", `${d.boxes?.[1] || 0} / ${d.boxes?.[2] || 0}`),
|
|
183
|
+
stat("Mastered", d.boxes?.[3] || 0),
|
|
184
|
+
stat("Sessions", d.sessions || 0)
|
|
185
|
+
);
|
|
186
|
+
el.dashboardBody.append(grid);
|
|
187
|
+
|
|
188
|
+
const domains = document.createElement("div");
|
|
189
|
+
for (const [id, v] of Object.entries(r.by_domain || {})) {
|
|
190
|
+
const row = document.createElement("div");
|
|
191
|
+
row.className = "domain-row";
|
|
192
|
+
const name = document.createElement("span");
|
|
193
|
+
name.textContent = id;
|
|
194
|
+
const bar = document.createElement("div");
|
|
195
|
+
bar.className = "bar";
|
|
196
|
+
const fill = document.createElement("span");
|
|
197
|
+
fill.style.width = `${Math.round((v.accuracy || 0) * 100)}%`;
|
|
198
|
+
bar.append(fill);
|
|
199
|
+
const num = document.createElement("span");
|
|
200
|
+
num.className = "muted";
|
|
201
|
+
num.textContent = `${v.seen}/${v.total}${v.accuracy == null ? "" : ` · ${Math.round(v.accuracy * 100)}%`}`;
|
|
202
|
+
row.append(name, bar, num);
|
|
203
|
+
domains.append(row);
|
|
204
|
+
}
|
|
205
|
+
el.dashboardBody.append(domains);
|
|
206
|
+
if (d.weak?.length) {
|
|
207
|
+
const weak = document.createElement("div");
|
|
208
|
+
weak.className = "weak-list";
|
|
209
|
+
weak.textContent = "Weakest domains: " + d.weak.map((x) => `${x.domain} ${Math.round((x.accuracy || 0) * 100)}%`).join(" · ");
|
|
210
|
+
el.dashboardBody.append(weak);
|
|
211
|
+
}
|
|
212
|
+
} catch (e) {
|
|
213
|
+
el.dashboardBody.textContent = e.message;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function loadTheory() {
|
|
218
|
+
try {
|
|
219
|
+
if (!store.theoryNodes) {
|
|
220
|
+
const data = await api("/api/theory");
|
|
221
|
+
store.theoryNodes = data.nodes || [];
|
|
222
|
+
el.theoryNodes.replaceChildren();
|
|
223
|
+
for (const n of store.theoryNodes) {
|
|
224
|
+
const b = document.createElement("button");
|
|
225
|
+
b.type = "button";
|
|
226
|
+
b.textContent = n.title || n.id;
|
|
227
|
+
b.dataset.node = n.id;
|
|
228
|
+
b.addEventListener("click", () => selectTheory(n.id));
|
|
229
|
+
el.theoryNodes.append(b);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const preferred = store.state?.q?.node || store.state?.teach?.node || store.theoryNodes[0]?.id;
|
|
233
|
+
if (preferred) await selectTheory(preferred);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
el.theoryBody.textContent = e.message;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function selectTheory(node) {
|
|
240
|
+
store.activeTheoryNode = node;
|
|
241
|
+
for (const b of el.theoryNodes.querySelectorAll("button")) b.classList.toggle("active", b.dataset.node === node);
|
|
242
|
+
el.theoryBody.textContent = "Loading...";
|
|
243
|
+
try {
|
|
244
|
+
const t = await api("/api/theory?node=" + encodeURIComponent(node));
|
|
245
|
+
el.theoryBody.replaceChildren();
|
|
246
|
+
const h = document.createElement("h2");
|
|
247
|
+
h.textContent = t.title || node;
|
|
248
|
+
const meta = document.createElement("p");
|
|
249
|
+
meta.className = "muted";
|
|
250
|
+
meta.textContent = [t.domain, t.summary].filter(Boolean).join(" · ");
|
|
251
|
+
el.theoryBody.append(h, meta);
|
|
252
|
+
const body = document.createElement("div");
|
|
253
|
+
renderMarkdownInto(body, t.section || "No theory section yet.");
|
|
254
|
+
el.theoryBody.append(body);
|
|
255
|
+
const doc = safeHttpUrl(t.docUrl || "");
|
|
256
|
+
if (doc) {
|
|
257
|
+
const p = document.createElement("p");
|
|
258
|
+
const a = document.createElement("a");
|
|
259
|
+
a.href = doc;
|
|
260
|
+
a.target = "_blank";
|
|
261
|
+
a.rel = "noreferrer";
|
|
262
|
+
a.textContent = doc;
|
|
263
|
+
p.append(a);
|
|
264
|
+
el.theoryBody.append(p);
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
el.theoryBody.textContent = e.message;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function loadLogs() {
|
|
272
|
+
el.logsBody.textContent = "Loading...";
|
|
273
|
+
try {
|
|
274
|
+
const data = await api("/api/log");
|
|
275
|
+
el.logsBody.textContent = data.text || "No study log yet.";
|
|
276
|
+
} catch (e) {
|
|
277
|
+
el.logsBody.textContent = e.message;
|
|
278
|
+
}
|
|
279
|
+
}
|
package/web/public/styles.css
CHANGED
|
@@ -324,6 +324,7 @@ a:hover { color: #183677; }
|
|
|
324
324
|
.chat-msg { max-width: 92%; padding: 9px 11px; border-radius: 11px; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
325
325
|
.chat-msg.user { align-self: flex-end; background: var(--cobalt-soft); border: 1px solid #cbd7f7; color: #183677; }
|
|
326
326
|
.chat-msg.assistant { align-self: flex-start; background: var(--surface-2); border: 1px solid var(--rule); }
|
|
327
|
+
.chat-msg.system { align-self: center; max-width: 100%; background: transparent; border: 1px dashed var(--rule); color: var(--ink-3, #6b6257); font-size: 11px; font-style: italic; padding: 4px 10px; }
|
|
327
328
|
.chat-msg.assistant p { margin: 0 0 7px; }
|
|
328
329
|
.chat-msg.assistant p:last-child { margin-bottom: 0; }
|
|
329
330
|
.chat-msg.assistant h1, .chat-msg.assistant h2, .chat-msg.assistant h3 { font-family: var(--font-display); font-size: 16px; margin: 10px 0 5px; }
|
package/web/server/chat.mjs
CHANGED
|
@@ -21,6 +21,15 @@ function textOf(message) {
|
|
|
21
21
|
.join("");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// A long study session eventually overflows a small-context model. When the child
|
|
25
|
+
// dies for that reason we compact instead of surfacing a dead chat.
|
|
26
|
+
const CONTEXT_OVERFLOW = /context (?:window|length|size)|context (?:is )?too (?:big|large|long)|maximum context|token limit|too many tokens|prompt is too long/i;
|
|
27
|
+
const DEFAULT_MAX_TURNS = 12;
|
|
28
|
+
|
|
29
|
+
function isContextOverflow(message) {
|
|
30
|
+
return CONTEXT_OVERFLOW.test(String(message || ""));
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
function contextBlock(state) {
|
|
25
34
|
const lines = [];
|
|
26
35
|
lines.push(`[cert] ${state?.cert || "?"} · ${state?.mode || "practice"} · phase=${state?.phase || "idle"}`);
|
|
@@ -46,7 +55,7 @@ function contextBlock(state) {
|
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
export class ChatBridge extends EventEmitter {
|
|
49
|
-
constructor({ root, cert, cliPath, sessionDir = "", session = false }) {
|
|
58
|
+
constructor({ root, cert, cliPath, sessionDir = "", session = false, maxTurns }) {
|
|
50
59
|
super();
|
|
51
60
|
this.root = root;
|
|
52
61
|
this.cert = cert;
|
|
@@ -56,6 +65,14 @@ export class ChatBridge extends EventEmitter {
|
|
|
56
65
|
this.rpc = null;
|
|
57
66
|
this.busy = false;
|
|
58
67
|
this.history = [];
|
|
68
|
+
// Context hygiene: rotate the pi child before it outgrows a small-context model,
|
|
69
|
+
// carrying a short recap of earlier turns into the fresh session.
|
|
70
|
+
const configured = Number(maxTurns ?? process.env.CERT_COACH_CHAT_MAX_TURNS ?? DEFAULT_MAX_TURNS);
|
|
71
|
+
this.maxTurns = Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_MAX_TURNS;
|
|
72
|
+
this.turns = 0;
|
|
73
|
+
this.rotations = 0;
|
|
74
|
+
this.recap = "";
|
|
75
|
+
this.lastRotateReason = "";
|
|
59
76
|
this.error = cliPath ? null : "Teaching chat is unavailable: no pi CLI found. Pass --pi <pi|path> or set PI_BIN.";
|
|
60
77
|
this._assistant = "";
|
|
61
78
|
this._tools = new Map();
|
|
@@ -67,6 +84,10 @@ export class ChatBridge extends EventEmitter {
|
|
|
67
84
|
running: Boolean(this.rpc?.running),
|
|
68
85
|
busy: this.busy,
|
|
69
86
|
error: this.error,
|
|
87
|
+
turns: this.turns,
|
|
88
|
+
maxTurns: this.maxTurns,
|
|
89
|
+
rotations: this.rotations,
|
|
90
|
+
lastRotateReason: this.lastRotateReason,
|
|
70
91
|
stderr: (this.rpc?.stderr || "").slice(-1200),
|
|
71
92
|
};
|
|
72
93
|
}
|
|
@@ -95,8 +116,19 @@ export class ChatBridge extends EventEmitter {
|
|
|
95
116
|
rpc.on("event", (ev) => this._onRpc(ev));
|
|
96
117
|
rpc.on("exit", (e) => {
|
|
97
118
|
this.busy = false;
|
|
98
|
-
this.error = e.message;
|
|
99
119
|
this.rpc = null;
|
|
120
|
+
if (isContextOverflow(e.message)) {
|
|
121
|
+
// Compact rather than dead-end: keep the learner's thread alive.
|
|
122
|
+
this.recap = this._buildRecap();
|
|
123
|
+
this.turns = 0;
|
|
124
|
+
this.rotations += 1;
|
|
125
|
+
this.lastRotateReason = "context-overflow";
|
|
126
|
+
this.error = null;
|
|
127
|
+
this._emitEvent({ type: "chat.rotated", reason: "context-overflow", rotations: this.rotations, hasRecap: Boolean(this.recap) });
|
|
128
|
+
this._emitEvent({ type: "chat.error", error: "Context limit reached. Earlier turns were summarised - your next message continues the conversation.", recoverable: true });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.error = e.message;
|
|
100
132
|
this._emitEvent({ type: "chat.error", error: e.message });
|
|
101
133
|
});
|
|
102
134
|
await rpc.start();
|
|
@@ -161,17 +193,55 @@ export class ChatBridge extends EventEmitter {
|
|
|
161
193
|
}
|
|
162
194
|
}
|
|
163
195
|
|
|
196
|
+
/** Short recap of earlier turns, injected once into a fresh session. */
|
|
197
|
+
_buildRecap(limit = 6) {
|
|
198
|
+
const users = this.history.filter((m) => m.role === "user").slice(-limit);
|
|
199
|
+
const lastAssistant = [...this.history].reverse().find((m) => m.role === "assistant");
|
|
200
|
+
const lines = [];
|
|
201
|
+
if (users.length) {
|
|
202
|
+
lines.push("Earlier in this session the learner asked:");
|
|
203
|
+
for (const m of users) lines.push(`- ${String(m.text).replace(/\s+/g, " ").slice(0, 160)}`);
|
|
204
|
+
}
|
|
205
|
+
if (lastAssistant?.text) {
|
|
206
|
+
lines.push(`Where we left off: ${String(lastAssistant.text).replace(/\s+/g, " ").slice(0, 400)}`);
|
|
207
|
+
}
|
|
208
|
+
return lines.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Stop the child so the next prompt starts a fresh pi session. */
|
|
212
|
+
async rotate(reason = "turn-limit") {
|
|
213
|
+
if (this.rpc) {
|
|
214
|
+
try { await this.rpc.stop(); } catch { /* ignore */ }
|
|
215
|
+
this.rpc = null;
|
|
216
|
+
}
|
|
217
|
+
this.recap = this._buildRecap();
|
|
218
|
+
this.turns = 0;
|
|
219
|
+
this.rotations += 1;
|
|
220
|
+
this.lastRotateReason = reason;
|
|
221
|
+
this.busy = false;
|
|
222
|
+
this._emitEvent({ type: "chat.rotated", reason, rotations: this.rotations, hasRecap: Boolean(this.recap) });
|
|
223
|
+
return this.recap;
|
|
224
|
+
}
|
|
225
|
+
|
|
164
226
|
async prompt(text, state) {
|
|
165
227
|
const clean = String(text || "").trim();
|
|
166
228
|
if (!clean) throw new CoreError("EMPTY_PROMPT", "Message is empty.");
|
|
229
|
+
if (this.turns >= this.maxTurns) await this.rotate("turn-limit");
|
|
167
230
|
await this.ensure();
|
|
168
231
|
this.history.push({ role: "user", text: clean, at: new Date().toISOString() });
|
|
169
232
|
if (this.history.length > 200) this.history = this.history.slice(-200);
|
|
170
233
|
this._emitEvent({ type: "chat.user", text: clean });
|
|
171
234
|
this.busy = true;
|
|
172
235
|
this.error = null;
|
|
173
|
-
const
|
|
174
|
-
|
|
236
|
+
const parts = [];
|
|
237
|
+
if (this.recap) {
|
|
238
|
+
parts.push(`[session recap - earlier turns were summarised to keep this conversation responsive]\n${this.recap}`);
|
|
239
|
+
this.recap = "";
|
|
240
|
+
}
|
|
241
|
+
parts.push(contextBlock(state));
|
|
242
|
+
parts.push(`[learner]\n${clean}`);
|
|
243
|
+
this.turns += 1;
|
|
244
|
+
const disposition = await this.rpc.prompt(parts.join("\n\n"));
|
|
175
245
|
return { disposition };
|
|
176
246
|
}
|
|
177
247
|
|