59fab56359
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
131 lines
5.4 KiB
PHP
131 lines
5.4 KiB
PHP
<?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),
|
|
],
|
|
];
|
|
}
|