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
68 lines
2.3 KiB
PHP
68 lines
2.3 KiB
PHP
<?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);
|