Files
coursehub/api/advisor.php
T
Harwinder Sekhon bb39771d8b Move the course advisor into a floating panel and link courses in its prose
The advisor was a card inside the home page, so reaching it meant navigating
back to #/ and it was rebuilt on every render of that view. It is now a bubble
in the bottom-right corner of every screen bar the player and a quiz, opening
a chat panel that stays put as you move around — ask what to take next, follow
a recommendation, keep asking. It is built once into #overlay-root, outside
the view that gets replaced, and syncAdvisor() called from renderChrome() is
the single path that decides whether it is on screen; that covers every route
change and every loadMeta(), and makes the Settings toggle work with no change
to its handler.

Being permanent removes the reason for the routeSeq guards in ask(). An answer
that arrived after the user navigated was written to sessionStorage and then
dropped unrendered; now it lands, and the bubble carries an unread dot if the
panel is closed. showAuth() wipes the transcript and hides the dock, both of
which are new requirements: the dock is a sibling of #app-shell, so hiding the
shell no longer hides the advisor, and the widget now outlives a sign-out.

Courses named in the answer itself are links. advisorResolveCourses() is
lifted out of advisorValidateRecommendations() so an id written inline goes
through the same existence check and the same redirect to whichever duplicate
copy the learner has progress on — a sentence and the card under it can never
point at different copies. The map is keyed by the id as the model wrote it,
since that is what the browser has to find in the text. The client splits the
raw line before escaping rather than linkifying escaped output: esc() writes
an apostrophe as ', and the course-id pattern matches the #39 inside it.

Under 900px the panel is a full-height sheet, and the mobile toast strip lifts
clear of the bubble only while the bubble is actually on screen. The z-index
sits on the dock rather than #overlay-root, which would otherwise become a
stacking context and trap the confirm dialog behind the panel.

README's advisor section is brought back in line, including the parts left
stale by 05f0abc about sections and the whole library going out per question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPZRrwVj6L9ckoRtK1yHAN
2026-09-10 06:56:01 -04:00

70 lines
2.4 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}],
// courses: {"412": {folder_id, name}, ...} (the "#412" mentions
// in `answer`, resolved so the browser can link them),
// 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);