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:
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
// POST api/advisor.php -> ask the AI course advisor
|
||||
// JSON body: {messages: [{role: 'user'|'assistant', content: string}, ...]}
|
||||
// (the whole conversation so far; the last entry is the question)
|
||||
// Response: {answer, recommended: [{folder_id, order, reason, course}],
|
||||
// from_ine: [{catalog_id, name, difficulty, vendor, url, reason}],
|
||||
// missing_prerequisites: [...], degraded,
|
||||
// usage: {prompt_tokens, cache_hit_tokens, completion_tokens,
|
||||
// reasoning_tokens, remaining_today}}
|
||||
// 503 when the advisor is switched off, 429 past the daily cap,
|
||||
// 502 when the AI service could not answer.
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/advisor.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
$userId = requireLoginApi();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
verifyCsrfApi($data);
|
||||
|
||||
if (!aiEnabled()) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['error' => 'The course advisor is switched off on this install.', 'advisor' => false]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$messages = advisorNormalizeMessages($data['messages'] ?? null);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$limit = advisorLimit();
|
||||
$used = advisorUsedToday($userId);
|
||||
if ($used >= $limit) {
|
||||
http_response_code(429);
|
||||
echo json_encode([
|
||||
'error' => "You have used today's $limit advisor question" . ($limit === 1 ? '' : 's')
|
||||
. ' — the count resets over the next 24 hours.',
|
||||
'remaining_today' => 0,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$requestId = advisorRecordUse($userId);
|
||||
set_time_limit(120);
|
||||
|
||||
try {
|
||||
$result = advisorAsk($userId, $messages);
|
||||
} catch (AiRejected | AiUnavailable | AiBadReply $e) {
|
||||
http_response_code(502);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
advisorMarkOk($requestId, $result['usage'], $result['model']);
|
||||
$result['usage']['remaining_today'] = max(0, $limit - $used - 1);
|
||||
unset($result['model']);
|
||||
echo json_encode($result);
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user