Seed the vendor catalogue into SQL and add an optional AI course advisor

Course metadata: a scan now gives every course a course_meta row from the
downloader's sidecar JSON when the folder has one, else from catalog_courses
matched by the folder name the downloader would create (folderKeyFor, a port
of ine.py's sanitize_name). tools/build-catalog-seed.php turns the INE export
into seeds/ine_catalog.sql, an idempotent patch that creates its own table and
upserts by id, so it can be applied to the live server before or after the
code and re-run freely. Course pages show the blurb, difficulty, vendor and
tags; search matches tags and blurbs; Settings reports how each course got its
details. Against the live library 163 of 179 courses match.

Course advisor, off by default: api/advisor.php answers "which course should I
take to learn X / what comes before Y" with courses from the library, in
order, plus vendor courses not yet downloaded. includes/ai_client.php is a
small curl wrapper for any OpenAI-format chat endpoint (DeepSeek V4 Flash by
default, no composer); the prompt is a byte-stable catalogue of the library so
the provider's prefix cache covers it. Every course id the model returns is
validated server-side, a bad JSON reply gets one retry then a plain-text
fallback, and a per-user daily cap with token counts lives in
advisor_requests. With AI_ENABLED false the app makes no outbound requests
and the box does not appear; users can also hide it per browser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
This commit is contained in:
Harwinder Sekhon
2026-09-08 18:36:43 -04:00
parent 6a09ae3833
commit 090d7fa94c
14 changed files with 1548 additions and 16 deletions
+238 -3
View File
@@ -130,7 +130,7 @@
/* ---------- preferences ---------- */
const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true };
const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true, advisor: true };
function pref(key) {
try {
@@ -164,7 +164,12 @@
showAuth();
throw new Error(data.error || 'Not logged in');
}
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
if (!res.ok) {
const err = new Error(data.error || `Request failed (${res.status})`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
@@ -638,6 +643,7 @@
<div class="eyebrow">${esc(todayLine())}</div>
<h1 style="margin:10px 0 24px">Pick up where you stopped.</h1>
${heroBlock(data.resume)}
${advisorBlock()}
${statCards(data.stats)}
<div class="section-head">
<h2>In progress</h2>
@@ -646,7 +652,187 @@
${progressBlock}
${recentBlock}
</div>`;
initAdvisor();
}
/* ---------- course advisor ---------- */
const ADVISOR_KEY = 'coursehub.advisor';
// Shown only when the server has it switched on and this browser has not hidden it.
const advisorOn = () => !!(state.meta && state.meta.advisor) && pref('advisor') !== false;
// The conversation lives in this tab: it survives navigating around the app
// and dies with the tab. It is replayed to the server on every question.
function advisorTurns() {
try {
const t = JSON.parse(sessionStorage.getItem(ADVISOR_KEY) || '[]');
return Array.isArray(t) ? t : [];
} catch (e) {
return [];
}
}
function advisorSave(turns) {
try { sessionStorage.setItem(ADVISOR_KEY, JSON.stringify(turns)); } catch (e) { /* not persisted */ }
}
/** The model's plain text as HTML: paragraphs, and "- " lines as bullets. Escaped first. */
function advisorAnswerHtml(text) {
const out = [];
let para = [];
let list = [];
const flushP = () => { if (para.length) { out.push(`<p>${para.map(esc).join('<br>')}</p>`); para = []; } };
const flushL = () => { if (list.length) { out.push(`<ul>${list.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>`); list = []; } };
for (const raw of String(text || '').split('\n')) {
const line = raw.trim();
const m = line.match(/^[-•*]\s+(.*)$/);
if (!line) { flushP(); flushL(); continue; }
if (m) { flushP(); list.push(m[1]); continue; }
flushL();
para.push(line);
}
flushP();
flushL();
return out.join('');
}
const safeHttpUrl = (u) => (/^https?:\/\//i.test(String(u || '')) ? String(u) : '');
function advisorTurnHtml(t) {
if (t.role === 'user') return `<div class="advisor-msg is-user">${esc(t.text)}</div>`;
const recs = (t.recommended || []).map((r) => {
const c = r.course;
const hue = hueOf(c.id);
return `<a class="row-card" href="#/folder/${r.folder_id}">
<span class="initial initial-sm" style="--c:${hue}">${r.order}</span>
<div class="row-main">
<div class="row-title">${esc(c.name)}</div>
<div class="row-sub">${esc([c.path, courseMeta(c)].filter(Boolean).join(' · '))}</div>
${r.reason ? `<div class="advisor-reason">${esc(r.reason)}</div>` : ''}
</div>
<div class="row-bar">${bar(c.progress.percent, hue)}<span class="mono">${c.progress.percent}%</span></div>
</a>`;
}).join('');
const ine = (t.from_ine || []).map((r) => {
const url = safeHttpUrl(r.url);
const body = `
<span class="initial initial-sm" style="--c:#756D5D">↗</span>
<div class="row-main">
<div class="row-title">${esc(r.name)}</div>
<div class="row-sub">${esc([r.vendor, r.difficulty, 'not downloaded yet'].filter(Boolean).join(' · '))}</div>
${r.reason ? `<div class="advisor-reason">${esc(r.reason)}</div>` : ''}
</div>`;
return url
? `<a class="row-card" href="${esc(url)}" target="_blank" rel="noopener">${body}</a>`
: `<div class="row-card">${body}</div>`;
}).join('');
const missing = (t.missing || []).map((m) => `<span class="pill pill-quiet">${esc(m)}</span>`).join('');
return `<div class="advisor-msg is-bot">
<div class="advisor-answer">${advisorAnswerHtml(t.answer)}</div>
${recs ? `<div class="advisor-label">Suggested order</div><div class="advisor-list">${recs}</div>` : ''}
${ine ? `<div class="advisor-label">Worth downloading</div><div class="advisor-list">${ine}</div>` : ''}
${missing ? `<div class="advisor-label">Not covered anywhere</div><div class="advisor-prereqs">${missing}</div>` : ''}
</div>`;
}
function advisorBlock() {
if (!advisorOn()) return '';
return `<section class="card advisor" id="advisor">
<div class="advisor-head">
<h2>Course advisor</h2>
<button class="btn-quiet" type="button" data-advisor-new>New conversation</button>
</div>
<p class="advisor-sub">Ask which course to take next, what to study first, or in what order — answered from your own library.</p>
<div class="advisor-thread" id="advisor-thread"></div>
<form class="advisor-form" id="advisor-form">
<textarea id="advisor-input" rows="1" placeholder="I want to learn SD-WAN — where should I start?"
aria-label="Ask the course advisor"></textarea>
<button class="btn btn-accent" type="submit">Ask</button>
</form>
<div class="advisor-note" id="advisor-note"></div>
</section>`;
}
function initAdvisor() {
const section = document.getElementById('advisor');
if (!section) return;
const thread = section.querySelector('#advisor-thread');
const form = section.querySelector('#advisor-form');
const input = section.querySelector('#advisor-input');
const note = section.querySelector('#advisor-note');
const submit = form.querySelector('button[type=submit]');
let turns = advisorTurns();
let busy = false;
const draw = (extra = '') => { thread.innerHTML = turns.map(advisorTurnHtml).join('') + extra; };
const setBusy = (b) => { busy = b; input.disabled = b; submit.disabled = b; };
draw();
// What the server replays to the model: the assistant's own JSON, so the
// follow-up sees exactly what it said before.
const messagesFor = (list) => list.map((t) => t.role === 'user'
? { role: 'user', content: t.text }
: { role: 'assistant', content: JSON.stringify({
answer: t.answer,
recommended: (t.recommended || []).map((r) => ({ folder_id: r.folder_id, order: r.order, reason: r.reason })),
from_ine: (t.from_ine || []).map((r) => ({ catalog_id: String(r.catalog_id).slice(0, 8), reason: r.reason })),
missing_prerequisites: t.missing || [],
}) });
async function ask(text) {
const seq = routeSeq;
turns.push({ role: 'user', text });
advisorSave(turns);
setBusy(true);
draw(`<div class="advisor-msg is-bot advisor-pending"><span class="spinner"></span>Thinking…</div>`);
try {
const res = await api('api/advisor.php', { json: { messages: messagesFor(turns) } });
turns.push({ role: 'assistant', answer: res.answer, recommended: res.recommended || [],
from_ine: res.from_ine || [], missing: res.missing_prerequisites || [] });
advisorSave(turns);
if (seq !== routeSeq || !thread.isConnected) return; // the user moved on meanwhile
draw();
const left = res.usage && res.usage.remaining_today;
note.textContent = left === undefined ? '' : `${left} question${left === 1 ? '' : 's'} left today.`;
if (res.degraded) toast('The advisor answered in plain text this time, without course links.', 'error');
} catch (err) {
turns.pop(); // the question goes back in the box
advisorSave(turns);
if (seq !== routeSeq || !thread.isConnected) return;
if (err.status === 503) { // switched off on the server since the page loaded
if (state.meta) state.meta.advisor = false;
section.remove();
toast('The course advisor is switched off.', 'error');
return;
}
input.value = text;
draw(`<div class="advisor-msg is-bot"><div class="alert">${esc(err.message)}</div></div>`);
} finally {
if (thread.isConnected) setBusy(false);
}
}
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || busy) return;
input.value = '';
ask(text);
});
input.addEventListener('keydown', (e) => { // Enter asks, Shift+Enter breaks the line
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
});
section.querySelector('[data-advisor-new]').addEventListener('click', () => {
if (busy) return;
turns = [];
advisorSave(turns);
note.textContent = '';
draw();
input.focus();
});
}
/* ---------- library ---------- */
@@ -754,6 +940,29 @@
/* ---------- folder ---------- */
/** What the catalogue (or the sidecar file) says about a course, under its title. */
function courseAbout(m) {
if (!m) return '';
const blurb = String(m.short_description || m.description || '');
const seen = new Set([m.difficulty, m.vendor].filter(Boolean).map((x) => x.toLowerCase()));
const chip = (text, cls) => {
if (!text || seen.has(text.toLowerCase())) return '';
seen.add(text.toLowerCase());
return `<span class="pill${cls ? ' ' + cls : ''}">${esc(text)}</span>`;
};
const chips = [
m.difficulty ? `<span class="pill">${esc(m.difficulty)}</span>` : '',
m.vendor ? `<span class="pill pill-quiet">${esc(m.vendor)}</span>` : '',
...(m.learning_paths || []).slice(0, 3).map((p) => chip(p, 'pill-quiet')),
...(m.tags || []).slice(0, 10).map((t) => chip(t, 'pill-quiet')),
].filter(Boolean).join('');
if (!blurb && !chips) return '';
return `<div class="course-about">
${blurb ? `<p>${esc(blurb.length > 420 ? blurb.slice(0, 419).trim() + '…' : blurb)}</p>` : ''}
${chips ? `<div class="chips-inline">${chips}</div>` : ''}
</div>`;
}
async function renderFolder(id) {
spinner();
const data = await load(`api/library.php?view=folder&id=${id}`);
@@ -825,6 +1034,7 @@
${data.progress.total ? resetBtn('folder', data.folder.id, data.folder.name) : ''}
</div>
</div>
${courseAbout(data.meta)}
<div class="bar-row" style="margin:0 0 32px">
${bar(data.progress.percent, hue)}
<span class="mono">${data.progress.percent}%</span>
@@ -1151,6 +1361,12 @@
<span class="mono">${data.complete_threshold}%</span></div>
<div class="fact-row"><span>Last scan</span>
<span class="mono">${esc(relTime(data.last_scan))}</span></div>
<div class="fact-row"><span>Catalogue</span>
<span class="mono">${data.catalog_courses ? `${data.catalog_courses} courses seeded` : 'not seeded'}</span></div>
${data.course_meta_counts ? `<div class="fact-row"><span>Course details</span>
<span class="mono">${data.course_meta_counts.sidecar} from files ·
${data.course_meta_counts.catalog} from the catalogue ·
${data.course_meta_counts.none} without</span></div>` : ''}
${data.root_exists ? '' : `<div class="alert" style="margin-top:14px">
This folder is not readable right now — scanning will fail until it is back.</div>`}
<div style="display:flex;gap:10px;margin-top:16px">
@@ -1173,6 +1389,24 @@
</div>`).join('')}
</section>
<section class="card settings-section">
<h3>Course advisor</h3>
${data.advisor && data.advisor.enabled ? `
<p>On — answers come from ${esc(data.advisor.model || 'the configured model')}.
You have used ${data.advisor.used_today} of ${data.advisor.limit} question${data.advisor.limit === 1 ? '' : 's'} today.</p>
<div class="toggle-row">
<div style="flex:1">
<div class="label">Show the advisor on the home page</div>
<div class="sub">Hides the box in this browser only; the server setting stays on.</div>
</div>
<button class="switch${pref('advisor') !== false ? ' is-on' : ''}" role="switch"
aria-checked="${pref('advisor') !== false}" aria-label="Show the advisor on the home page"
data-toggle="advisor"><span></span></button>
</div>`
: `<p>Off. Set AI_ENABLED and AI_API_KEY in config.php to turn it on — the app works the same without it,
and makes no outbound requests while it is off.</p>`}
</section>
<section class="card settings-section is-danger">
<h3>Reset progress</h3>
<p>You have progress on ${data.progress_courses} of ${data.courses} course${data.courses === 1 ? '' : 's'}.
@@ -1351,7 +1585,8 @@
try {
const res = await api('api/scan.php', { method: 'POST' });
const s = res.stats;
toast(`Scan finished — ${s.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
toast(`Scan finished — ${s.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed`
+ (s.meta_none === undefined ? '.' : `; details for ${s.courses - s.meta_none} of ${s.courses} courses.`));
await loadMeta();
route();
} catch (err) {