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:
Harwinder Sekhon
2026-09-18 19:58:13 -04:00
parent 07b7aa758c
commit c0c626f61d
2 changed files with 143 additions and 47 deletions
+122 -36
View File
@@ -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;
@@ -228,12 +257,19 @@ function advisorLibrary(): array
}
foreach ($courses as &$c) {
$m = $c['meta'] ?? [];
$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) {
@@ -276,13 +320,27 @@ function advisorRelevant(array $conversation, int $userId): array
if (isset($c['terms'][$field][$t])) $s += $w * $qw;
}
}
$pct = $progress[$id]['percent'] ?? 0;
if ($pct > 0 && $pct < 100) $s += 1000; // in progress: always in
if (isset($named[$id])) $s += 500; // already on the table
$pct = $progress[$id]['percent'] ?? 0;
$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];