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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user