feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { normalizeAgentLessonContent } from '../services/agent/lesson-content';
import { normalizeAgentLessonContent } from '@cio/core/services/agent/lesson-content';
describe('normalizeAgentLessonContent', () => {
it('removes a leading heading that duplicates the lesson title', () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { computePurchasedTokenOverflow } from '@api/services/agent/usage';
import { computePurchasedTokenOverflow } from '@cio/core/services/agent/usage';
describe('computePurchasedTokenOverflow', () => {
it('returns 0 when usage stays within allowance', () => {
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import { balanceCorrectAnswerPositions } from '@cio/core/services/agent/balance-answer-positions';
import { QUESTION_TYPE } from '@cio/utils/validation/constants';
type Option = { label: string; isCorrect: boolean };
type Question = { questionTypeId: number; question: string; options: Option[] };
function radio(question: string, labels: string[], correctIndex: number): Question {
return {
questionTypeId: QUESTION_TYPE.RADIO,
question,
options: labels.map((label, i) => ({ label, isCorrect: i === correctIndex }))
};
}
function correctPosition(question: Question): number {
return question.options.findIndex((option) => option.isCorrect);
}
describe('balanceCorrectAnswerPositions', () => {
it('spreads the correct RADIO position across the batch instead of clustering', () => {
// 12 questions, all authored with the answer at index 0 ("A is always right").
const questions = Array.from({ length: 12 }, (_, i) => radio(`Q${i}`, ['alpha', 'bravo', 'charlie', 'delta'], 0));
const balanced = balanceCorrectAnswerPositions(questions);
const counts = [0, 0, 0, 0];
balanced.forEach((question) => counts[correctPosition(question)]++);
// 12 questions / 4 options → exactly 3 per position when perfectly balanced.
expect(counts).toEqual([3, 3, 3, 3]);
});
it('keeps the same correct option, only its position changes', () => {
const questions = [radio('Q', ['alpha', 'bravo', 'charlie', 'delta'], 0)];
const [balanced] = balanceCorrectAnswerPositions(questions);
const correct = balanced.options.find((option) => option.isCorrect);
expect(correct?.label).toBe('alpha');
expect(balanced.options).toHaveLength(4);
expect(new Set(balanced.options.map((o) => o.label))).toEqual(new Set(['alpha', 'bravo', 'charlie', 'delta']));
// Exactly one correct option survives.
expect(balanced.options.filter((o) => o.isCorrect)).toHaveLength(1);
});
it('is deterministic for identical input', () => {
const build = () => Array.from({ length: 8 }, (_, i) => radio(`Q${i}`, ['a', 'b', 'c', 'd'], 0));
const first = balanceCorrectAnswerPositions(build()).map(correctPosition);
const second = balanceCorrectAnswerPositions(build()).map(correctPosition);
expect(first).toEqual(second);
});
it('does not shuffle options that reference other positions', () => {
const questions = [radio('Q', ['Paris', 'London', 'Both of the above', 'All of the above'], 3)];
const [balanced] = balanceCorrectAnswerPositions(questions);
expect(balanced.options.map((o) => o.label)).toEqual(['Paris', 'London', 'Both of the above', 'All of the above']);
});
it('leaves naturally-ordered numeric ranges untouched', () => {
const questions = [radio('Range?', ['0-10', '11-20', '21-30', '31-40'], 1)];
const [balanced] = balanceCorrectAnswerPositions(questions);
expect(balanced.options.map((o) => o.label)).toEqual(['0-10', '11-20', '21-30', '31-40']);
});
it('balances each option-count group independently', () => {
const threeOption = Array.from({ length: 6 }, (_, i) => radio(`T${i}`, ['x', 'y', 'z'], 0));
const fourOption = Array.from({ length: 8 }, (_, i) => radio(`F${i}`, ['a', 'b', 'c', 'd'], 0));
const balanced = balanceCorrectAnswerPositions([...threeOption, ...fourOption]);
const threeCounts = [0, 0, 0];
balanced.slice(0, 6).forEach((q) => threeCounts[correctPosition(q)]++);
expect(threeCounts).toEqual([2, 2, 2]);
const fourCounts = [0, 0, 0, 0];
balanced.slice(6).forEach((q) => fourCounts[correctPosition(q)]++);
expect(fourCounts).toEqual([2, 2, 2, 2]);
});
it('shuffles CHECKBOX options without dropping correct answers', () => {
// Correct answers authored at the top; expect them not all clustered after shuffle.
const questions: Question[] = Array.from({ length: 10 }, (_, i) => ({
questionTypeId: QUESTION_TYPE.CHECKBOX,
question: `C${i}`,
options: [
{ label: 'one', isCorrect: true },
{ label: 'two', isCorrect: true },
{ label: 'three', isCorrect: false },
{ label: 'four', isCorrect: false }
]
}));
const balanced = balanceCorrectAnswerPositions(questions);
balanced.forEach((question) => {
expect(question.options.filter((o) => o.isCorrect)).toHaveLength(2);
expect(new Set(question.options.map((o) => o.label))).toEqual(new Set(['one', 'two', 'three', 'four']));
});
// Index 0 should not be correct in every single question once shuffled.
const indexZeroAlwaysCorrect = balanced.every((q) => q.options[0].isCorrect);
expect(indexZeroAlwaysCorrect).toBe(false);
});
it('leaves TRUE_FALSE and malformed questions untouched', () => {
const trueFalse: Question = {
questionTypeId: QUESTION_TYPE.TRUE_FALSE,
question: 'TF',
options: [
{ label: 'True', isCorrect: true },
{ label: 'False', isCorrect: false }
]
};
const noCorrect = radio('Q', ['a', 'b', 'c'], -1);
const [balancedTf, balancedNone] = balanceCorrectAnswerPositions([trueFalse, noCorrect]);
expect(balancedTf.options.map((o) => o.label)).toEqual(['True', 'False']);
expect(balancedNone.options.map((o) => o.label)).toEqual(['a', 'b', 'c']);
});
});
@@ -4,13 +4,13 @@ import { getCourseById } from '@cio/db/queries/course';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from '../services/course/course';
import { ensureCourseSlug, generateUniqueCourseSlug } from '../services/course/landing-page';
import { updateCourse } from '@cio/core/services/course/course';
import { ensureCourseSlug, generateUniqueCourseSlug } from '@cio/core/services/course/landing-page';
import {
evaluateCourseGoLiveReadiness,
getCourseGoLiveReadiness,
publishCourseWhenReady
} from '../services/course/go-live-readiness';
} from '@cio/core/services/course/go-live-readiness';
vi.mock('@cio/db/queries/course', () => ({
getCourseById: vi.fn()
@@ -28,16 +28,19 @@ vi.mock('@cio/db/queries/organization', () => ({
getOrganizationById: vi.fn()
}));
vi.mock('../services/course/course', () => ({
vi.mock('@cio/core/services/course/course', () => ({
updateCourse: vi.fn()
}));
vi.mock('../services/course/landing-page', () => ({
buildCourseBaseUrl: vi.fn(() => 'https://school.example.com'),
vi.mock('@cio/core/services/course/landing-page', () => ({
ensureCourseSlug: vi.fn(),
generateUniqueCourseSlug: vi.fn()
}));
vi.mock('@cio/core/config/dashboard-url', () => ({
getDashboardBaseUrl: vi.fn(() => 'https://school.example.com')
}));
const mockedGetCourseById = vi.mocked(getCourseById);
const mockedGetCourseContentItems = vi.mocked(getCourseContentItems);
const mockedGetCourseOrganizationId = vi.mocked(getCourseOrganizationId);
@@ -3,7 +3,7 @@ import {
buildUpdatedQuestions,
type CurrentQuestionForPatch,
type QuestionPatch
} from '@api/services/agent/question-update';
} from '@cio/core/services/agent/question-update';
import { QUESTION_TYPE } from '@cio/utils/validation/constants';
const EXERCISE_ID = 'ex-1';