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
+67
View File
@@ -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);