cert-coach-web 0.1.0 → 0.1.1
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 +8 -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 +15 -118
- package/web/public/index.html +1 -1
- package/web/public/spa-logic.js +20 -0
- package/web/public/spa-render.js +107 -0
- package/web/public/styles.css +1 -0
- package/web/server/chat.mjs +74 -4
- package/web/server/http.mjs +6 -0
package/AGENTS.md
CHANGED
|
@@ -54,7 +54,9 @@ Load the extension: `pi --extension ./.pi/extensions/cert-coach.ts`.
|
|
|
54
54
|
|
|
55
55
|
## Current certs
|
|
56
56
|
|
|
57
|
-
- `DP600/` — Microsoft Fabric Analytics Engineer (DP-600). Blueprint verified 2026-10-21 (skills measured 2026-10-19). Bank: 117 items, 74 gradeable, 110 verified (
|
|
58
|
-
- `PL300/` — Microsoft Power BI Data Analyst (PL-300).
|
|
57
|
+
- `DP600/` — Microsoft Fabric Analytics Engineer (DP-600). Blueprint verified 2026-10-21 (skills measured 2026-10-19). Bank: 117 items, 74 gradeable, 110 verified (105 confirmed, 5 corrected), 7 unverifiable, 0 pending; all 16 authored gap questions human-reviewed. Free half: 21/43 pages. `DP600/theory.md` is a manual `DEEP_PASS`. Direct Lake on OneLake has **no** DirectQuery fallback; only the SQL analytics endpoint variant does.
|
|
58
|
+
- `PL300/` — Microsoft Power BI Data Analyst (PL-300). Current **four** domains (25–30/25–30/25–30/15–20). Bank: 234 items, 155 gradeable, 231 verified (230 confirmed, 1 corrected), 3 unverifiable, 0 pending. Free half 47/95 pages.
|
|
59
|
+
- `DATABRICKSDA/` — Databricks Certified Data Analyst Associate (DA-ASSOC, 45 MCQ / 90 min). Blueprint verified 2026-10-26: nine domains 11/8/5/20/15/16/12/5/8. **Dump ingested, verified, and gap-filled 2026-10-26**: bank **106 items, 91 gradeable, all answer-verified** (88 confirmed · 3 corrected · 15 unverifiable), **0 pending**, **0 unmapped**; **36-node DAG, all 36 used**, a hands-on `lab` per node, thin-bank gate `minCoverage: 0.5`. Every domain is now within ~±5 pts of blueprint share. 10 items are medium confidence — a targeted docs pass would firm them up. Note: docs have rebranded Delta Sharing → **OpenSharing** and Genie → a family (Genie One / Genie Agents / Genie Code); stems use exam-era wording, keywords carry both.
|
|
60
|
+
- `SAPC02/` — AWS Certified Solutions Architect – Professional (SAP-C02: 75 questions delivered = 65 scored + 10 unscored, 180 min, pass 750/1000, $300, 3-yr). Blueprint **fetched and verified 2026-10-26**: four domains 26/29/25/20. Bootstrap starter bank: 20 gradeable scenario items (all doc-verified), 31-node DAG with a hands-on AWS `lab` per node, thin-bank gate `minCoverage: 0.5`. Scenario/trade-off exam; Domain 2 is heaviest (genAI/Bedrock, event-driven, Control Tower), and scored content is 65 — not 75. ⚠️ **SAP-C02 last test day 2026-11-16; SAP-C03 registration opens 2026-10-27** (`cert_meta.lifecycle`) — confirm the booked version. Dump still to ingest.
|
|
59
61
|
|
|
60
62
|
Do not trust counts written in prose; `cert_status` and `validate.mjs` are the source of truth.
|
package/README.md
CHANGED
|
@@ -23,9 +23,10 @@ Counts are live in `progress.json` / `/api/status`; this is a snapshot.
|
|
|
23
23
|
|---|---:|---:|---|---:|
|
|
24
24
|
| **DP-600** | 117 | 74 | 105 confirmed · 5 corrected · 7 unverifiable · 0 pending | 22% |
|
|
25
25
|
| **PL-300** | 234 | 155 | 230 confirmed · 1 corrected · 3 unverifiable · 0 pending | 23% |
|
|
26
|
+
| **Databricks DA** | 24 | 24 | 24 confirmed · 0 corrected · 0 unverifiable · 0 pending | bootstrap |
|
|
26
27
|
|
|
27
28
|
- **Works today:** TUI (`cert_*` tools) + web console (Study, Path, Dashboard, Theory, Logs, teaching chat), Chat v2 (read + research + verify), full mock, live Mermaid, browser cert scaffold, `pi` auto-install, OpenRouter free-model config.
|
|
28
|
-
- **Green:**
|
|
29
|
+
- **Green:** 67 tests, both certs `validate` PASS, `npm pack` 64 files / no banks.
|
|
29
30
|
- **Next:** npm publish; generated-question review; optional extension slimming.
|
|
30
31
|
|
|
31
32
|
## Quickstart
|
|
@@ -189,7 +190,7 @@ server/ web console server (server-owned StudyLoop, SSE, auth
|
|
|
189
190
|
public/ study SPA (Study, Path, Dashboard, Theory, Logs, chat)
|
|
190
191
|
bin/ web console entry point
|
|
191
192
|
tests/ deterministic core/server/web/chat/mock tests
|
|
192
|
-
DP600/ PL300/
|
|
193
|
+
DP600/ PL300/ DATABRICKSDA/ SAPC02/ internal data (shareable as-is; progress.json is owner-only)
|
|
193
194
|
```
|
|
194
195
|
|
|
195
196
|
## Move your own workspace to another PC
|
|
@@ -200,7 +201,8 @@ For a private move (your own second machine), use the archive script:
|
|
|
200
201
|
powershell -ExecutionPolicy Bypass -File engine/scripts/export_private.ps1
|
|
201
202
|
```
|
|
202
203
|
|
|
203
|
-
It writes a zip to your Desktop containing the engine **and** `DP600/`, `PL300
|
|
204
|
+
It writes a zip to your Desktop containing the engine **and** `DP600/`, `PL300/`,
|
|
205
|
+
`DATABRICKSDA/`, `SAPC02/`
|
|
204
206
|
including `raw/`, verified `bank.json`, and `vision_assets/`. Progress is reset
|
|
205
207
|
in the archive and study logs are removed by default. Page state (`.cert-coach/`)
|
|
206
208
|
and `node_modules/` are excluded. Use `-KeepProgress` to include attempts/history.
|
|
@@ -260,6 +262,8 @@ npm run test:e2e:real
|
|
|
260
262
|
|
|
261
263
|
The Chrome test checks question render, no key before submit, feedback, Next, Path, and chat/tool cards. Playwright uses the installed Chrome channel. The header **+ New** button scaffolds a workspace from the browser; research and ingest still run through the agent (see `BROWSER_CERT_CREATION.md`).
|
|
262
264
|
|
|
265
|
+
Long chat sessions are compacted automatically so a small-context model never dies mid-study. After **12 learner turns** the server starts a fresh pi session and carries a recap of the earlier questions forward; if the model reports a context-limit error anyway, the same compaction happens on the spot and the chat keeps working. Tune the threshold with `CERT_COACH_CHAT_MAX_TURNS` (or `maxTurns` on `ChatBridge`). The chat log shows a dashed *session compacted* line when it happens.
|
|
266
|
+
|
|
263
267
|
Mock: choose **10 / 20 / 65 questions** and **untimed / 90 / 130 minutes** before starting. The summary is isolated from `progress.json`.
|
|
264
268
|
|
|
265
269
|
Mermaid fences in agent chat/theory now render as live diagrams via a local `public/vendor/mermaid.min.js`.
|
|
@@ -317,5 +321,5 @@ Pi: `cert_setup`, `cert_status`, `cert_ask`, `cert_next`, `cert_answer`, `cert_v
|
|
|
317
321
|
- **DP-600:** 74/74 gradeable items verified; ledger 117 items (105 confirmed, 5 corrected, 7 unverifiable, 0 pending). Free half only (21/43 pages) — full coverage needs a paid account and `MAX_PAGES`.
|
|
318
322
|
- **PL-300:** 155/155 gradeable items verified; ledger 234 items (230 confirmed, 1 corrected, 3 unverifiable, 0 pending). Free half (47/95 pages). `manage_secure` is ~14.5% of the bank vs the 15–20% blueprint.
|
|
319
323
|
- **Image items are not lost.** 42 DP-600 / 79 PL-300 items depend on exhibits or code images. They are marked `needs_vision` with asset URLs; a vision pass can recover them.
|
|
320
|
-
- **Data is internal-shareable, as-is.** `DP600
|
|
324
|
+
- **Data is internal-shareable, as-is.** `DP600/`, `PL300/`, `DATABRICKSDA/`, and `SAPC02/` (banks, `raw/`, `vision_assets/`) ship in the *internal* package only, never the public npm tarball. Scraping is still subject to the source site's terms, so keep the internal export inside your org. `progress.json` is owner-only and reset on export.
|
|
321
325
|
- **The extension is pi-only.** The portable protocol is the certification-coach skill; the deterministic state layer requires pi.
|
|
@@ -143,12 +143,17 @@ export function computeReadiness(c) {
|
|
|
143
143
|
const gradeable = c.bank.filter((q) => q.gradeable).length || 1;
|
|
144
144
|
const seen = Object.values(by).reduce((a, b) => a + b.seen, 0);
|
|
145
145
|
const coverage = seen / gradeable;
|
|
146
|
-
const
|
|
146
|
+
const gate = c.meta.readiness || {};
|
|
147
|
+
const minAccuracy = Number.isFinite(gate.minAccuracy) ? gate.minAccuracy : 0.85;
|
|
148
|
+
const minCoverage = Number.isFinite(gate.minCoverage) ? gate.minCoverage : 0.8;
|
|
149
|
+
const label =
|
|
150
|
+
value >= minAccuracy && coverage >= minCoverage ? "ready" : value >= 0.65 ? "borderline" : "not_ready";
|
|
147
151
|
c.progress.readiness = {
|
|
148
152
|
method: "blueprint_weighted_last_answer_accuracy",
|
|
149
153
|
value: Number(value.toFixed(4)),
|
|
150
154
|
label,
|
|
151
155
|
coverage: Number(coverage.toFixed(4)),
|
|
156
|
+
gate: { minAccuracy, minCoverage },
|
|
152
157
|
by_domain: Object.fromEntries(
|
|
153
158
|
Object.entries(by).map(([k, v]) => [k, { ...v, accuracy: v.seen ? Number((v.lastCorrect / v.seen).toFixed(3)) : null }])
|
|
154
159
|
),
|
package/engine/extractor.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
30
|
|
|
31
31
|
function detectCert() {
|
|
32
|
-
if (INJECTED_CERT && INJECTED_CERT
|
|
32
|
+
if (INJECTED_CERT && !INJECTED_CERT.includes("{")) return INJECTED_CERT;
|
|
33
33
|
if (window.CERT_CODE) return window.CERT_CODE;
|
|
34
34
|
const text = `${location.href} ${document.title} ${document.body?.innerText?.slice(0, 4000) || ""}`;
|
|
35
35
|
const m = text.match(/\b([A-Z]{2,4}-\d{3})\b/);
|
|
@@ -17,10 +17,9 @@ if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
|
|
17
17
|
New-Item -ItemType Directory -Force $stage | Out-Null
|
|
18
18
|
|
|
19
19
|
$items = @(
|
|
20
|
-
"AGENTS.md", "README.md", ".
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"DP600", "PL300"
|
|
20
|
+
"AGENTS.md", "README.md", "EERO_PORT.md", "BROWSER_CERT_CREATION.md", "LICENSE", "package.json",
|
|
21
|
+
".pi", "engine", "web", "tests",
|
|
22
|
+
"DP600", "PL300", "DATABRICKSDA"
|
|
24
23
|
)
|
|
25
24
|
foreach ($item in $items) {
|
|
26
25
|
$src = Join-Path $Repo $item
|
|
@@ -28,10 +27,13 @@ foreach ($item in $items) {
|
|
|
28
27
|
Copy-Item $src (Join-Path $stage $item) -Recurse -Force
|
|
29
28
|
}
|
|
30
29
|
}
|
|
30
|
+
# Never ship dependencies; they are reinstalled on the other PC.
|
|
31
|
+
Get-ChildItem $stage -Recurse -Directory -Filter "node_modules" -ErrorAction SilentlyContinue |
|
|
32
|
+
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
|
31
33
|
|
|
32
34
|
# Reset learner state in the archive (not in the real workspace) unless asked.
|
|
33
35
|
if (-not $KeepProgress) {
|
|
34
|
-
foreach ($cert in @("DP600", "PL300")) {
|
|
36
|
+
foreach ($cert in @("DP600", "PL300", "DATABRICKSDA")) {
|
|
35
37
|
$certDir = Join-Path $stage $cert
|
|
36
38
|
if (-not (Test-Path $certDir)) { continue }
|
|
37
39
|
$metaPath = Join-Path $certDir "cert_meta.json"
|
|
@@ -80,7 +82,7 @@ if (-not $KeepProgress) {
|
|
|
80
82
|
@"
|
|
81
83
|
PRIVATE CERT COACH WORKSPACE — DO NOT REDISTRIBUTE
|
|
82
84
|
|
|
83
|
-
This archive includes the engine plus DP600
|
|
85
|
+
This archive includes the engine plus DP600, PL300, and DATABRICKSDA cert data (raw dumps,
|
|
84
86
|
verified banks, vision assets). Progress is reset; study logs are removed.
|
|
85
87
|
|
|
86
88
|
On the other PC:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cert-coach-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local web study console for a pi certification coach: server-owned quiz loop, images, and teaching chat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "node web/server/main.mjs",
|
|
15
|
-
"test": "node --test tests/core.test.mjs tests/server.test.mjs tests/web.test.mjs tests/chat.test.mjs tests/mock.test.mjs",
|
|
15
|
+
"test": "node --test tests/core.test.mjs tests/server.test.mjs tests/web.test.mjs tests/chat.test.mjs tests/mock.test.mjs tests/spa.test.mjs tests/spa-render.test.mjs tests/ingest.test.mjs tests/validate.test.mjs tests/extractor.test.mjs",
|
|
16
16
|
"pack:check": "npm pack --dry-run",
|
|
17
17
|
"test:e2e:chrome": "node tests/e2e-chrome.mjs",
|
|
18
18
|
"test:package": "node tests/package-smoke.mjs",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"access": "public"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@playwright/test": "^1.64.1"
|
|
67
|
+
"@playwright/test": "^1.64.1",
|
|
68
|
+
"jsdom": "^30.1.3"
|
|
68
69
|
}
|
|
69
70
|
}
|
package/web/public/app.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { safeHttpUrl, imageSrc } from "./spa-logic.js";
|
|
2
|
+
import { markdownFragment } from "./spa-render.js";
|
|
3
|
+
|
|
1
4
|
const $ = (id) => document.getElementById(id);
|
|
2
5
|
|
|
3
6
|
const el = {
|
|
@@ -165,22 +168,6 @@ function setPhase(phase) {
|
|
|
165
168
|
el.phasePill.textContent = phase || "idle";
|
|
166
169
|
}
|
|
167
170
|
|
|
168
|
-
function safeHttpUrl(value) {
|
|
169
|
-
try {
|
|
170
|
-
const u = new URL(value);
|
|
171
|
-
return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
|
|
172
|
-
} catch {
|
|
173
|
-
return "";
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function imageSrc(im) {
|
|
178
|
-
const src = String(im?.src || "");
|
|
179
|
-
if (/^https?:\/\//i.test(src)) return "/api/asset?src=" + encodeURIComponent(src);
|
|
180
|
-
if (src.startsWith("/assets/")) return src;
|
|
181
|
-
return "";
|
|
182
|
-
}
|
|
183
|
-
|
|
184
171
|
function renderImages(images) {
|
|
185
172
|
el.images.replaceChildren();
|
|
186
173
|
for (const im of images || []) {
|
|
@@ -456,108 +443,8 @@ async function startPractice(mode = "practice") {
|
|
|
456
443
|
}
|
|
457
444
|
}
|
|
458
445
|
|
|
459
|
-
function inlineNodes(text) {
|
|
460
|
-
const frag = document.createDocumentFragment();
|
|
461
|
-
const re = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\(https?:\/\/[^)\s]+\))/g;
|
|
462
|
-
let last = 0;
|
|
463
|
-
for (const m of text.matchAll(re)) {
|
|
464
|
-
if (m.index > last) frag.append(document.createTextNode(text.slice(last, m.index)));
|
|
465
|
-
const tok = m[0];
|
|
466
|
-
if (tok.startsWith("`")) {
|
|
467
|
-
const code = document.createElement("code");
|
|
468
|
-
code.textContent = tok.slice(1, -1);
|
|
469
|
-
frag.append(code);
|
|
470
|
-
} else if (tok.startsWith("**")) {
|
|
471
|
-
const b = document.createElement("strong");
|
|
472
|
-
b.textContent = tok.slice(2, -2);
|
|
473
|
-
frag.append(b);
|
|
474
|
-
} else {
|
|
475
|
-
const mm = tok.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
|
|
476
|
-
const a = document.createElement("a");
|
|
477
|
-
a.textContent = mm?.[1] || tok;
|
|
478
|
-
a.href = safeHttpUrl(mm?.[2] || "");
|
|
479
|
-
a.target = "_blank";
|
|
480
|
-
a.rel = "noreferrer";
|
|
481
|
-
frag.append(a);
|
|
482
|
-
}
|
|
483
|
-
last = m.index + tok.length;
|
|
484
|
-
}
|
|
485
|
-
if (last < text.length) frag.append(document.createTextNode(text.slice(last)));
|
|
486
|
-
return frag;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function markdownFragment(text) {
|
|
490
|
-
const frag = document.createDocumentFragment();
|
|
491
|
-
const lines = String(text || "").split(/\r?\n/);
|
|
492
|
-
let i = 0;
|
|
493
|
-
let para = [];
|
|
494
|
-
const flush = () => {
|
|
495
|
-
if (!para.length) return;
|
|
496
|
-
const p = document.createElement("p");
|
|
497
|
-
p.append(inlineNodes(para.join(" ")));
|
|
498
|
-
frag.append(p);
|
|
499
|
-
para = [];
|
|
500
|
-
};
|
|
501
|
-
while (i < lines.length) {
|
|
502
|
-
const line = lines[i];
|
|
503
|
-
if (/^```/.test(line)) {
|
|
504
|
-
flush();
|
|
505
|
-
const lang = line.slice(3).trim();
|
|
506
|
-
i++;
|
|
507
|
-
const code = [];
|
|
508
|
-
while (i < lines.length && !/^```/.test(lines[i])) code.push(lines[i++]);
|
|
509
|
-
if (i < lines.length) i++;
|
|
510
|
-
const pre = document.createElement("pre");
|
|
511
|
-
if (lang === "mermaid") pre.className = "diagram";
|
|
512
|
-
const c = document.createElement("code");
|
|
513
|
-
c.textContent = code.join("\n");
|
|
514
|
-
pre.append(c);
|
|
515
|
-
frag.append(pre);
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
519
|
-
if (h) {
|
|
520
|
-
flush();
|
|
521
|
-
const elh = document.createElement("h" + h[1].length);
|
|
522
|
-
elh.append(inlineNodes(h[2]));
|
|
523
|
-
frag.append(elh);
|
|
524
|
-
i++;
|
|
525
|
-
continue;
|
|
526
|
-
}
|
|
527
|
-
if (/^\s*[-*]\s+/.test(line)) {
|
|
528
|
-
flush();
|
|
529
|
-
const ul = document.createElement("ul");
|
|
530
|
-
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
531
|
-
const li = document.createElement("li");
|
|
532
|
-
li.append(inlineNodes(lines[i].replace(/^\s*[-*]\s+/, "")));
|
|
533
|
-
ul.append(li);
|
|
534
|
-
i++;
|
|
535
|
-
}
|
|
536
|
-
frag.append(ul);
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
if (/^>\s?/.test(line)) {
|
|
540
|
-
flush();
|
|
541
|
-
const quote = document.createElement("blockquote");
|
|
542
|
-
quote.append(inlineNodes(line.replace(/^>\s?/, "")));
|
|
543
|
-
frag.append(quote);
|
|
544
|
-
i++;
|
|
545
|
-
continue;
|
|
546
|
-
}
|
|
547
|
-
if (!line.trim()) {
|
|
548
|
-
flush();
|
|
549
|
-
i++;
|
|
550
|
-
continue;
|
|
551
|
-
}
|
|
552
|
-
para.push(line);
|
|
553
|
-
i++;
|
|
554
|
-
}
|
|
555
|
-
flush();
|
|
556
|
-
return frag;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
446
|
function renderMarkdownInto(node, text) {
|
|
560
|
-
node.replaceChildren(markdownFragment(text));
|
|
447
|
+
node.replaceChildren(markdownFragment(document, text));
|
|
561
448
|
void renderMermaidInto(node);
|
|
562
449
|
}
|
|
563
450
|
|
|
@@ -569,7 +456,7 @@ function chatScroll(force = false) {
|
|
|
569
456
|
|
|
570
457
|
function appendChatMessage(role, text) {
|
|
571
458
|
const div = document.createElement("div");
|
|
572
|
-
div.className = "chat-msg " + (role === "user" ? "user" : "assistant");
|
|
459
|
+
div.className = "chat-msg " + (role === "user" ? "user" : role === "system" ? "system" : "assistant");
|
|
573
460
|
if (role === "assistant") renderMarkdownInto(div, text);
|
|
574
461
|
else div.textContent = text;
|
|
575
462
|
el.chatLog.append(div);
|
|
@@ -680,6 +567,16 @@ function handleChatEvent(event) {
|
|
|
680
567
|
showNotice(event.error || "Chat failed.");
|
|
681
568
|
break;
|
|
682
569
|
}
|
|
570
|
+
case "chat.rotated": {
|
|
571
|
+
// The server compacted the session before it ran out of context.
|
|
572
|
+
appendChatMessage(
|
|
573
|
+
"system",
|
|
574
|
+
event.reason === "context-overflow"
|
|
575
|
+
? "Context limit reached - earlier turns were summarised. Keep going."
|
|
576
|
+
: "Long session - starting a fresh context with a recap of earlier turns."
|
|
577
|
+
);
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
683
580
|
default:
|
|
684
581
|
break;
|
|
685
582
|
}
|
package/web/public/index.html
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
<h1>Ready when you are</h1>
|
|
53
53
|
<p id="idle-note" class="muted"></p>
|
|
54
54
|
<div class="mock-controls">
|
|
55
|
-
<label>Mock <select id="mock-size"><option value="10">10 questions</option><option value="20">20 questions</option><option value="65" selected>65 questions</option></select></label>
|
|
55
|
+
<label>Mock <select id="mock-size"><option value="10">10 questions</option><option value="20">20 questions</option><option value="45">45 questions</option><option value="65" selected>65 questions</option></select></label>
|
|
56
56
|
<label>Time <select id="mock-minutes"><option value="0">untimed</option><option value="90">90 min</option><option value="130" selected>130 min</option></select></label>
|
|
57
57
|
</div>
|
|
58
58
|
<div class="actions">
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Pure, DOM-free SPA helpers. Kept in a module so they are unit-testable in
|
|
2
|
+
// node (app.js itself touches `document` at load, so it cannot be imported).
|
|
3
|
+
|
|
4
|
+
/** Allow only http(s) URLs; anything else (javascript:, data:, relative) -> "". */
|
|
5
|
+
export function safeHttpUrl(value) {
|
|
6
|
+
try {
|
|
7
|
+
const u = new URL(value);
|
|
8
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
|
|
9
|
+
} catch {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Remote exhibits go through the guarded proxy; local /assets/ pass through. */
|
|
15
|
+
export function imageSrc(im) {
|
|
16
|
+
const src = String(im?.src || "");
|
|
17
|
+
if (/^https?:\/\//i.test(src)) return "/api/asset?src=" + encodeURIComponent(src);
|
|
18
|
+
if (src.startsWith("/assets/")) return src;
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|
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
|
|
package/web/server/http.mjs
CHANGED
|
@@ -386,6 +386,12 @@ export function createStudyServer({ root, loop, token = "", chat = null, runtime
|
|
|
386
386
|
if (method === "GET" && url.pathname === "/app.js") {
|
|
387
387
|
return sendFile(res, path.join(PUBLIC, "app.js"), "text/javascript; charset=utf-8");
|
|
388
388
|
}
|
|
389
|
+
if (method === "GET" && url.pathname === "/spa-logic.js") {
|
|
390
|
+
return sendFile(res, path.join(PUBLIC, "spa-logic.js"), "text/javascript; charset=utf-8");
|
|
391
|
+
}
|
|
392
|
+
if (method === "GET" && url.pathname === "/spa-render.js") {
|
|
393
|
+
return sendFile(res, path.join(PUBLIC, "spa-render.js"), "text/javascript; charset=utf-8");
|
|
394
|
+
}
|
|
389
395
|
if (method === "GET" && url.pathname === "/styles.css") {
|
|
390
396
|
return sendFile(res, path.join(PUBLIC, "styles.css"), "text/css; charset=utf-8");
|
|
391
397
|
}
|