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
+130
View File
@@ -0,0 +1,130 @@
<?php
// The one place CourseHub talks to an AI provider: OpenAI-format chat
// completions over curl — DeepSeek by default, any compatible endpoint via
// AI_BASE_URL / AI_MODEL in config.php. Nothing here runs unless aiEnabled(),
// and nothing else in the app makes outbound requests.
require_once __DIR__ . '/db.php'; // config constants
class AiUnavailable extends RuntimeException {} // network, timeout, 5xx — try later
class AiRejected extends RuntimeException {} // 4xx — the request, the key or the quota
class AiBadReply extends RuntimeException {} // 200, but nothing usable in it
/** The master switch: config says on, a key is set, and curl is there to use it. */
function aiEnabled(): bool
{
return defined('AI_ENABLED') && AI_ENABLED === true
&& defined('AI_API_KEY') && is_string(AI_API_KEY) && AI_API_KEY !== ''
&& function_exists('curl_init');
}
function aiModel(): string
{
return (defined('AI_MODEL') && is_string(AI_MODEL) && AI_MODEL !== '') ? AI_MODEL : 'deepseek-v4-flash';
}
function aiBaseUrl(): string
{
$url = (defined('AI_BASE_URL') && is_string(AI_BASE_URL) && AI_BASE_URL !== '')
? AI_BASE_URL : 'https://api.deepseek.com';
return rtrim($url, '/');
}
/**
* One chat completion, no streaming.
*
* $messages: [['role' => 'system'|'user'|'assistant', 'content' => string], ...]
* $opts: json — ask for a JSON object (the prompt must still say so)
* max_tokens — cap on the reply (default 4000)
* reasoning — 'off' | 'low' | 'high' | 'max' (default 'low')
* timeout — seconds to wait for the whole reply (default 90)
*
* Returns ['content' => string, 'finish_reason' => ?string, 'model' => string,
* 'usage' => [prompt_tokens, cache_hit_tokens, completion_tokens, reasoning_tokens]].
*/
function aiChat(array $messages, array $opts = []): array
{
if (!aiEnabled()) {
throw new AiUnavailable('The AI advisor is switched off.');
}
$body = [
'model' => aiModel(),
'messages' => $messages,
'stream' => false,
'max_tokens' => max(256, (int)($opts['max_tokens'] ?? 4000)),
];
if (!empty($opts['json'])) {
$body['response_format'] = ['type' => 'json_object'];
}
// DeepSeek's thinking switch: `thinking.type` turns it on or off and
// `reasoning_effort` (top level) says how hard. Other OpenAI-format servers
// may reject unknown fields — set AI_REASONING_PARAM to false in config.php
// to leave both out.
if (!defined('AI_REASONING_PARAM') || AI_REASONING_PARAM !== false) {
$reasoning = (string)($opts['reasoning'] ?? 'low');
if ($reasoning === 'off') {
$body['thinking'] = ['type' => 'disabled'];
} else {
$body['thinking'] = ['type' => 'enabled'];
$body['reasoning_effort'] = $reasoning;
}
}
$ch = curl_init(aiBaseUrl() . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . AI_API_KEY,
],
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => max(10, (int)($opts['timeout'] ?? 90)),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false || $errno !== 0) {
throw new AiUnavailable('Could not reach the AI service' . ($error ? " ($error)" : '') . '.');
}
$data = json_decode((string)$raw, true);
if ($status >= 500) {
throw new AiUnavailable("The AI service is having trouble (HTTP $status) — try again later.");
}
if ($status === 429) {
throw new AiRejected('The AI service is busy — try again in a moment.');
}
if ($status >= 400) {
$msg = is_array($data) ? ($data['error']['message'] ?? null) : null;
throw new AiRejected('The AI service rejected the request'
. (is_string($msg) && $msg !== '' ? ': ' . mb_substr($msg, 0, 200) : " (HTTP $status)."));
}
if (!is_array($data)) {
throw new AiBadReply('The AI service sent something that is not JSON.');
}
$choice = $data['choices'][0] ?? null;
$content = is_array($choice) ? ($choice['message']['content'] ?? '') : '';
if (!is_string($content) || trim($content) === '') {
throw new AiBadReply('The AI service returned an empty reply.');
}
$u = is_array($data['usage'] ?? null) ? $data['usage'] : [];
return [
'content' => $content,
'finish_reason' => is_array($choice) ? ($choice['finish_reason'] ?? null) : null,
'model' => is_string($data['model'] ?? null) ? $data['model'] : aiModel(),
'usage' => [
'prompt_tokens' => (int)($u['prompt_tokens'] ?? 0),
'cache_hit_tokens' => (int)($u['prompt_cache_hit_tokens'] ?? 0),
'completion_tokens' => (int)($u['completion_tokens'] ?? 0),
'reasoning_tokens' => (int)($u['completion_tokens_details']['reasoning_tokens'] ?? 0),
],
];
}