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.

Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
This commit is contained in:
Harwinder Sekhon
2026-09-08 18:36:43 -04:00
parent 04314327db
commit 59fab56359
14 changed files with 1548 additions and 16 deletions
+13
View File
@@ -10,6 +10,7 @@
// GET api/library.php?view=settings -> library paths, thresholds, progress summary
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/advisor.php'; // aiEnabled() and the usage counters only
header('Content-Type: application/json');
$userId = requireLoginApi();
@@ -95,6 +96,7 @@ switch ($view) {
],
'last_scan' => appMetaGet('last_scan_at'),
'username' => currentUsername(),
'advisor' => aiEnabled(),
]);
break;
@@ -179,6 +181,9 @@ switch ($view) {
'rollup' => $rollup,
'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'],
'percent' => $rollup['percent']],
// What the catalogue or the sidecar file says about a course; null
// for sections, categories and courses that came from elsewhere.
'meta' => $kind === 'course' ? courseMetaPublic(courseMeta((int)$folder['id'])) : null,
'categories' => $categories,
'courses' => $courses,
'subfolders' => $subfolders,
@@ -282,6 +287,14 @@ switch ($view) {
'progress_courses' => count($withSome),
'courses' => count($courses),
'shelf_counts' => $counts,
'catalog_courses' => catalogCourseCount(),
'course_meta_counts' => json_decode((string)appMetaGet('course_meta_counts'), true) ?: null,
'advisor' => [
'enabled' => aiEnabled(),
'model' => aiEnabled() ? aiModel() : null,
'used_today' => aiEnabled() ? advisorUsedToday($userId) : 0,
'limit' => advisorLimit(),
],
]);
break;