Tier course contents by relevance, only sending video titles for matched courses
Split the relevant courses into two tiers: ~25 courses described in detail (paths, tags, about), and ~6 of those get their full video titles when the question points at them specifically. This lets the advisor answer what a course covers without sending the entire library's 3,000 video titles every time. Video titles now become searchable terms weighted alongside tags and learning paths, so courses are found by their actual content.
This commit is contained in:
@@ -42,9 +42,9 @@ plain HTTP resources.
|
||||
belongs to) from the vendor's catalogue or the downloader's sidecar file —
|
||||
see *Course catalogue* below.
|
||||
- An optional **AI course advisor**, a bubble in the corner of every screen:
|
||||
"I want to learn SD-WAN, where do I start?" answered with courses from your
|
||||
own library, in order, with links. Off unless you switch it on — see
|
||||
*Course advisor* below.
|
||||
"I want to learn SD-WAN, where do I start?" or "what does Core MPLS cover?"
|
||||
answered with courses from your own library, in order, with links. Off unless
|
||||
you switch it on — see *Course advisor* below.
|
||||
- **Reset progress** for one video, one quiz, a whole course/section, or the
|
||||
entire account.
|
||||
- Course and section listings switch between **grid and list** layout, and the
|
||||
@@ -312,9 +312,10 @@ lesson) is ignored.
|
||||
|
||||
A bubble in the bottom-right corner of every screen — bar the player and a
|
||||
quiz, where it would be in the way — opening a chat panel that answers
|
||||
questions like *"I want to learn SD-WAN, where do I start?"* or *"what should
|
||||
I know before Core MPLS?"* with courses from your own library, in a suggested
|
||||
order, each linking to its course page with your progress on it. Courses named
|
||||
questions like *"I want to learn SD-WAN, where do I start?"*, *"what should I
|
||||
know before Core MPLS?"* or *"what topics does Core MPLS cover?"* with courses
|
||||
from your own library, in a suggested order, each linking to its course page
|
||||
with your progress on it. Courses named
|
||||
in the answer itself are links too. When something you need is not on the
|
||||
server but is in the seeded catalogue, it says so and links to the vendor's
|
||||
page; topics covered nowhere are listed too.
|
||||
@@ -340,15 +341,24 @@ user can also hide the button for their browser from Settings.
|
||||
|
||||
What leaves the server when it is on: the name, category, difficulty, vendor
|
||||
and size of every course, a short blurb, tags and learning paths for the
|
||||
handful judged relevant to the question, the catalogue titles of courses you
|
||||
have access to but have not downloaded, your progress percentage per course,
|
||||
and the conversation you type. Never video files, section or lesson names,
|
||||
subtitles, or quiz contents.
|
||||
handful judged relevant to the question, the section and video **titles** of
|
||||
the few courses that question is actually about, the catalogue titles of
|
||||
courses you have access to but have not downloaded, your progress percentage
|
||||
per course, and the conversation you type. Never video files, subtitles, or
|
||||
quiz contents.
|
||||
|
||||
How it works: every course goes out as a one-line index, which is the same
|
||||
bytes for every user and every question, so the provider's prefix cache covers
|
||||
it and a question costs a fraction of a cent; only the courses that match the
|
||||
question are described in any depth. The model replies in JSON; the server
|
||||
question are described in any depth. Depth comes in two tiers: about 25 courses
|
||||
get their learning paths, tags and blurb, and of those the half-dozen the
|
||||
question actually names or points at also get every video title, grouped by
|
||||
section — which is what lets it answer what a course covers without sending the
|
||||
whole library's 3,000-odd titles every time. The question's own words are
|
||||
matched against those titles as well, so a course is found by what its videos
|
||||
teach and not only by what its blurb says. Being part-way through a course puts
|
||||
it in the first tier but not the second: that is a reason to describe a course,
|
||||
not a question about what is in it. The model replies in JSON; the server
|
||||
drops any course id it made up before the browser sees it, and links are built
|
||||
only from ids that exist. That applies to the prose as well — an id the model
|
||||
writes inline as `#412` is resolved against the same check and becomes a link
|
||||
|
||||
+119
-33
@@ -8,7 +8,9 @@ require_once __DIR__ . '/ai_client.php';
|
||||
|
||||
const ADVISOR_DETAIL_COURSES = 25; // courses described in depth, picked by advisorRelevant()
|
||||
const ADVISOR_CATALOG_SECTIONS = false; // per-section sizes in the details (was 40% of the prompt)
|
||||
const ADVISOR_CATALOG_VIDEO_TITLES = false; // per-video titles in the details (~3x larger again)
|
||||
const ADVISOR_CONTENTS_COURSES = 6; // courses whose video titles go in, picked by the question
|
||||
const ADVISOR_CONTENTS_MAX_TITLES = 150; // titles listed for one course
|
||||
const ADVISOR_CONTENTS_TITLE_CHARS = 90; // of one video title
|
||||
const ADVISOR_CATALOG_ABOUT_CHARS = 160; // of a course's blurb
|
||||
const ADVISOR_CATALOG_MAX_TAGS = 8;
|
||||
const ADVISOR_CATALOG_INE = true; // list catalogue courses that are not downloaded
|
||||
@@ -140,6 +142,23 @@ function advisorTerms(string $text): array
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matching terms for a course's video titles. Bare numbers under 100 go: they
|
||||
* are the sequence prefix and the "Part 2" marker the folder numbering leaves
|
||||
* on nearly every title ("09_LDP_Features"), and left in, any question with a
|
||||
* small number in it matches half the library. Longer numbers stay, so a video
|
||||
* title can still be what finds the 350-401 exam course.
|
||||
*/
|
||||
function advisorTitleTerms(array $titles): array
|
||||
{
|
||||
$terms = advisorTerms(implode(' ', $titles));
|
||||
foreach ($terms as $t => $_) {
|
||||
// A numeric term arrives as an int key, hence the cast.
|
||||
if (ctype_digit((string)$t) && (int)$t < 100) unset($terms[$t]);
|
||||
}
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* The library once, shaped for the prompt: one entry per course with its
|
||||
* metadata, sizes, sections and search terms. Memoised per request — the
|
||||
@@ -159,10 +178,18 @@ function advisorLibrary(): array
|
||||
$db = db();
|
||||
$meta = courseMetaAll();
|
||||
|
||||
$videos = $quizzes = [];
|
||||
foreach ($db->query('SELECT folder_id, COUNT(*) AS n, COALESCE(SUM(duration_seconds), 0) AS d
|
||||
FROM videos WHERE is_missing = 0 GROUP BY folder_id') as $r) {
|
||||
$videos[(int)$r['folder_id']] = [(int)$r['n'], (float)$r['d']];
|
||||
// One pass over the videos for counts, durations and titles. The titles are
|
||||
// what lets the advisor say what a course covers and what a question's
|
||||
// words are matched against; every title in the library is ~30k tokens, so
|
||||
// only the courses the question points at carry theirs into the prompt.
|
||||
$videos = $titles = $quizzes = [];
|
||||
foreach ($db->query('SELECT folder_id, name, duration_seconds FROM videos
|
||||
WHERE is_missing = 0 ORDER BY folder_id, sort_order, name') as $r) {
|
||||
$fid = (int)$r['folder_id'];
|
||||
$videos[$fid] ??= [0, 0.0];
|
||||
$videos[$fid][0]++;
|
||||
$videos[$fid][1] += (float)$r['duration_seconds'];
|
||||
$titles[$fid][] = $r['name'];
|
||||
}
|
||||
foreach ($db->query('SELECT folder_id, COUNT(*) AS n FROM quizzes WHERE is_missing = 0
|
||||
GROUP BY folder_id') as $r) {
|
||||
@@ -194,7 +221,8 @@ function advisorLibrary(): array
|
||||
$d += $sd;
|
||||
if ($sv === 0 && $sq === 0) continue; // a container, not a section
|
||||
$secs[] = ['id' => $sid, 'rel' => substr($s['path'], strlen($c['path']) + 1),
|
||||
'videos' => $sv, 'quizzes' => $sq, 'duration' => $sd];
|
||||
'videos' => $sv, 'quizzes' => $sq, 'duration' => $sd,
|
||||
'titles' => $titles[$sid] ?? []];
|
||||
}
|
||||
$category = dirname($c['path']);
|
||||
$key = mb_strtolower(folderKeyFor($c['name']));
|
||||
@@ -202,6 +230,7 @@ function advisorLibrary(): array
|
||||
'id' => $id, 'name' => $c['name'], 'path' => $c['path'], 'depth' => (int)$c['depth'],
|
||||
'category' => $category === '.' ? '' : $category, 'key' => $key, 'meta' => $m,
|
||||
'videos' => $v, 'quizzes' => $q, 'duration' => $d, 'sections' => $secs,
|
||||
'titles' => $titles[$id] ?? [], // videos sitting in the course root itself
|
||||
];
|
||||
$ine['keys'][$key] = true;
|
||||
if ($m && $m['catalog_id']) $ine['ids'][$m['catalog_id']] = true;
|
||||
@@ -229,11 +258,18 @@ function advisorLibrary(): array
|
||||
|
||||
foreach ($courses as &$c) {
|
||||
$m = $c['meta'] ?? [];
|
||||
$vid = $c['titles'];
|
||||
foreach ($c['sections'] as $s) array_push($vid, ...$s['titles']);
|
||||
$c['terms'] = [
|
||||
'title' => advisorTerms($c['name'] . ' ' . ($m['title'] ?? '')),
|
||||
'tags' => advisorTerms(implode(' ', $m['tags'] ?? [])),
|
||||
'paths' => advisorTerms(implode(' ', array_merge($m['learning_paths'] ?? [], $m['categories'] ?? []))),
|
||||
'category' => advisorTerms($c['category']),
|
||||
// The video titles: what the course actually teaches, so "OSPF LSA
|
||||
// types" finds the course with that video even when the blurb and
|
||||
// the tags never say it. A set, so a long course is not scored up
|
||||
// for repeating the same word across forty videos.
|
||||
'videos' => advisorTitleTerms($vid),
|
||||
'about' => advisorTerms(($m['short_description'] ?? '') . ' ' . ($m['description'] ?? '')),
|
||||
];
|
||||
}
|
||||
@@ -247,11 +283,19 @@ function advisorLibrary(): array
|
||||
* learner's words point at, what they have in progress, and what the advisor
|
||||
* already named. A plain term match, done here for free, so the model reads
|
||||
* ADVISOR_DETAIL_COURSES candidates in depth instead of every course in brief.
|
||||
*
|
||||
* Returns two lists. `ids` are the courses to describe; `contents` is the
|
||||
* short head of them that also gets its video titles, ranked by what the
|
||||
* question asks about rather than by the blended score — being part-way
|
||||
* through a course is a reason to describe it, not a question about what it
|
||||
* covers, and the in-progress bonus would otherwise fill every slot.
|
||||
*
|
||||
* @return array{ids: int[], contents: int[]}
|
||||
*/
|
||||
function advisorRelevant(array $conversation, int $userId): array
|
||||
{
|
||||
$lib = advisorLibrary();
|
||||
$weights = ['title' => 5, 'tags' => 3, 'paths' => 3, 'category' => 2, 'about' => 1];
|
||||
$weights = ['title' => 5, 'tags' => 3, 'paths' => 3, 'category' => 2, 'videos' => 2, 'about' => 1];
|
||||
|
||||
// The latest question counts double; earlier turns keep the thread's topic in view.
|
||||
$query = [];
|
||||
@@ -268,7 +312,7 @@ function advisorRelevant(array $conversation, int $userId): array
|
||||
}
|
||||
$progress = advisorProgress($userId);
|
||||
|
||||
$scores = [];
|
||||
$scores = $asked = [];
|
||||
foreach ($lib['courses'] as $id => $c) {
|
||||
$s = 0;
|
||||
foreach ($weights as $field => $w) {
|
||||
@@ -277,12 +321,26 @@ function advisorRelevant(array $conversation, int $userId): array
|
||||
}
|
||||
}
|
||||
$pct = $progress[$id]['percent'] ?? 0;
|
||||
if ($pct > 0 && $pct < 100) $s += 1000; // in progress: always in
|
||||
$live = $pct > 0 && $pct < 100;
|
||||
// Asked about: the question's own words, plus an id either side wrote.
|
||||
// In progress counts for a single point, so a course being worked
|
||||
// through only takes a contents slot no question is competing for.
|
||||
$a = $s + (isset($named[$id]) ? 500 : 0) + ($live ? 1 : 0);
|
||||
if ($a > 0) $asked[$id] = $a;
|
||||
if ($live) $s += 1000; // in progress: always in
|
||||
if (isset($named[$id])) $s += 500; // already on the table
|
||||
if ($s > 0) $scores[$id] = $s;
|
||||
}
|
||||
arsort($scores); // stable: ties keep library order
|
||||
return array_slice(array_keys($scores), 0, ADVISOR_DETAIL_COURSES);
|
||||
arsort($asked);
|
||||
$ids = array_slice(array_keys($scores), 0, ADVISOR_DETAIL_COURSES);
|
||||
$kept = array_fill_keys($ids, true);
|
||||
return [
|
||||
'ids' => $ids,
|
||||
'contents' => array_slice(
|
||||
array_filter(array_keys($asked), fn($id) => isset($kept[$id])),
|
||||
0, ADVISOR_CONTENTS_COURSES),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,30 +395,55 @@ function advisorCatalogText(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* The retrieved courses in depth — paths, tags, blurb, and sections / video
|
||||
* titles when switched on. Changes with every question, so it sits after the
|
||||
* cached index.
|
||||
* What one course actually teaches: its video titles, in order, under the
|
||||
* section each belongs to. This is the block that answers "what does this
|
||||
* course cover?", so the titles go out verbatim rather than as counts.
|
||||
*
|
||||
* Long courses are cut at ADVISOR_CONTENTS_MAX_TITLES and say how many are
|
||||
* left, so one 400-video course cannot crowd out the rest of the prompt.
|
||||
*/
|
||||
function advisorDetailsText(array $ids): string
|
||||
function advisorContentsLines(array $c): array
|
||||
{
|
||||
$groups = [];
|
||||
if ($c['titles']) $groups[] = ['', $c['titles']]; // loose in the course root
|
||||
foreach ($c['sections'] as $s) {
|
||||
if ($s['titles']) $groups[] = [$s['rel'], $s['titles']];
|
||||
}
|
||||
if (!$groups) return [];
|
||||
|
||||
$total = array_sum(array_map(fn($g) => count($g[1]), $groups));
|
||||
$shown = 0;
|
||||
$body = [];
|
||||
foreach ($groups as [$rel, $titles]) {
|
||||
$room = ADVISOR_CONTENTS_MAX_TITLES - $shown;
|
||||
if ($room <= 0) break;
|
||||
$take = array_slice($titles, 0, $room);
|
||||
$shown += count($take);
|
||||
$body[] = ' ' . ($rel === '' ? '' : $rel . ': ')
|
||||
. implode('; ', array_map(fn($t) => advisorClip($t, ADVISOR_CONTENTS_TITLE_CHARS), $take));
|
||||
}
|
||||
if ($shown < $total) {
|
||||
$body[] = sprintf(' … and %d more video%s', $total - $shown, $total - $shown === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
$secs = count(array_filter($groups, fn($g) => $g[0] !== ''));
|
||||
$head = sprintf(' covers %d video%s', $total, $total === 1 ? '' : 's');
|
||||
if ($secs) $head .= sprintf(' in %d section%s', $secs, $secs === 1 ? '' : 's');
|
||||
return array_merge([$head . ':'], $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The retrieved courses in depth — paths, tags, blurb, and, for the few the
|
||||
* question actually asks about, every video title. Changes with every
|
||||
* question, so it sits after the cached index.
|
||||
*/
|
||||
function advisorDetailsText(array $ids, array $contents = []): string
|
||||
{
|
||||
if (!$ids) return '';
|
||||
$lib = advisorLibrary();
|
||||
$full = array_fill_keys($contents, true);
|
||||
$lines = ['COURSE DETAILS (the courses most likely relevant to this conversation; every other course is in the index only):'];
|
||||
|
||||
$titles = [];
|
||||
if (ADVISOR_CATALOG_VIDEO_TITLES) {
|
||||
$folderIds = [];
|
||||
foreach ($ids as $id) {
|
||||
$folderIds[] = $id;
|
||||
foreach ($lib['courses'][$id]['sections'] as $s) $folderIds[] = $s['id'];
|
||||
}
|
||||
$in = implode(',', array_map('intval', $folderIds));
|
||||
foreach (db()->query("SELECT folder_id, name FROM videos WHERE is_missing = 0 AND folder_id IN ($in)
|
||||
ORDER BY folder_id, sort_order, name") as $r) {
|
||||
$titles[(int)$r['folder_id']][] = $r['name'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$c = $lib['courses'][$id];
|
||||
$m = $c['meta'];
|
||||
@@ -369,15 +452,15 @@ function advisorDetailsText(array $ids): string
|
||||
if ($m && $m['tags']) $lines[] = ' tags: ' . implode(', ', array_slice($m['tags'], 0, ADVISOR_CATALOG_MAX_TAGS));
|
||||
$about = $m ? ($m['short_description'] ?: $m['description']) : null;
|
||||
if ($about) $lines[] = ' about: ' . advisorClip($about, ADVISOR_CATALOG_ABOUT_CHARS);
|
||||
if (ADVISOR_CATALOG_SECTIONS && $c['sections']) {
|
||||
$covers = isset($full[$id]) ? advisorContentsLines($c) : [];
|
||||
if (ADVISOR_CATALOG_SECTIONS && $c['sections'] && !$covers) {
|
||||
$secLines = [];
|
||||
foreach ($c['sections'] as $s) {
|
||||
$secLines[] = $s['rel'] . ' (' . advisorCounts($s['videos'], $s['quizzes'], $s['duration']) . ')';
|
||||
if (isset($titles[$s['id']])) $secLines[] = '[' . implode('; ', $titles[$s['id']]) . ']';
|
||||
}
|
||||
$lines[] = ' sections: ' . implode('; ', $secLines);
|
||||
}
|
||||
if (isset($titles[$id])) $lines[] = ' videos: ' . implode('; ', $titles[$id]);
|
||||
array_push($lines, ...$covers);
|
||||
}
|
||||
return implode("\n", $lines) . "\n";
|
||||
}
|
||||
@@ -426,6 +509,8 @@ You are the course advisor for a private, self-hosted video library. The learner
|
||||
|
||||
Rules:
|
||||
- Recommend only courses that appear in LIBRARY INDEX, referenced by their #id. Never invent a course or an id. A course listed in the index but not in COURSE DETAILS is still a valid recommendation.
|
||||
- A "covers:" block lists a course's actual video titles, section by section. When asked what a course covers, answer from those titles — group them into themes rather than reciting the list, and never add a topic that is not there. Use them to judge a match too: a video title is better evidence than a blurb.
|
||||
- Only the courses the question points at have a "covers:" block. For a course without one, say what the index and details do show, and that you can go through its videos if asked about it directly.
|
||||
- "recommended" is a learning sequence: order 1 first. Leave it empty when nothing in the library fits.
|
||||
- "from_ine" lists catalogue courses (by their @id, from the NOT DOWNLOADED section) that fill a real gap in the sequence — a prerequisite or the natural next step the library lacks. Leave it empty otherwise.
|
||||
- "missing_prerequisites" names topics the learner should know that neither list covers.
|
||||
@@ -439,7 +524,7 @@ Rules:
|
||||
Reply with a single json object and nothing else, exactly this shape:
|
||||
{"answer":"Start with Core MPLS #412, then ...","recommended":[{"folder_id":412,"order":1,"reason":"Covers the fundamentals first."}],"from_ine":[{"catalog_id":"c8d205e0","reason":"The lab bootcamp that follows it."}],"missing_prerequisites":["Basic IP routing"]}
|
||||
|
||||
Legend — LIBRARY INDEX lines: "#id | category path | title | difficulty | vendor | size". COURSE DETAILS: "#id title", then "paths:" (learning paths), "tags:" and "about:". NOT DOWNLOADED lines: "@id | title | difficulty | vendor | paths:".
|
||||
Legend — LIBRARY INDEX lines: "#id | category path | title | difficulty | vendor | size". COURSE DETAILS: "#id title", then "paths:" (learning paths), "tags:", "about:", and "covers N videos in M sections:" followed by indented "section name: video title; video title" lines. NOT DOWNLOADED lines: "@id | title | difficulty | vendor | paths:".
|
||||
|
||||
TXT;
|
||||
}
|
||||
@@ -638,8 +723,9 @@ function advisorValidateFromIne(array $items): array
|
||||
*/
|
||||
function advisorAsk(int $userId, array $conversation): array
|
||||
{
|
||||
$relevant = advisorRelevant($conversation, $userId);
|
||||
$system = advisorSystemPrompt() . "\n" . advisorCatalogText()
|
||||
. "\n" . advisorDetailsText(advisorRelevant($conversation, $userId))
|
||||
. "\n" . advisorDetailsText($relevant['ids'], $relevant['contents'])
|
||||
. "\nLEARNER PROGRESS:\n" . advisorProgressText($userId);
|
||||
$messages = array_merge([['role' => 'system', 'content' => $system]], $conversation);
|
||||
$opts = ['json' => true, 'max_tokens' => ADVISOR_MAX_TOKENS, 'reasoning' => ADVISOR_REASONING];
|
||||
|
||||
Reference in New Issue
Block a user