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:
+238
-3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user