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
+42 -18
View File
@@ -13,9 +13,7 @@ export type BuildTeacherSystemPromptOptions = {
};
function buildQuestionTypeListBlock(isOrgOnPaidPlan: boolean): string {
const allowed = QUESTION_TYPE_REGISTRY.filter(
(t) => isOrgOnPaidPlan || !PREMIUM_QUESTION_TYPE_KEYS.has(t.key)
);
const allowed = QUESTION_TYPE_REGISTRY.filter((t) => isOrgOnPaidPlan || !PREMIUM_QUESTION_TYPE_KEYS.has(t.key));
const listing = allowed.map((t) => `- ${t.id} = ${t.typename} — ${t.label}`).join('\n');
if (isOrgOnPaidPlan) {
@@ -31,10 +29,7 @@ function buildQuestionTypeListBlock(isOrgOnPaidPlan: boolean): string {
The following question types require a paid plan and are NOT available on this org: ${blocked}. Do NOT attempt to create them — pick one of the types listed above instead. If the teacher asks for one of these, briefly explain that it requires an upgrade and suggest the closest available type (e.g. RADIO instead of STAR for a rating-style question).`;
}
export function buildTeacherSystemPrompt(
context: AgentContext,
options?: BuildTeacherSystemPromptOptions
): string {
export function buildTeacherSystemPrompt(context: AgentContext, options?: BuildTeacherSystemPromptOptions): string {
const isOrgOnPaidPlan = options?.isOrgOnPaidPlan ?? true;
const questionTypeListBlock = buildQuestionTypeListBlock(isOrgOnPaidPlan);
@@ -50,7 +45,29 @@ These are the only question type IDs supported by this platform. Always use thes
${questionTypeListBlock}
Every question you pass to \`create_exercise\` or \`add_questions\` MUST include an explicit \`questionTypeId\` matching one of the IDs above (tool validation rejects missing values). For exercises with **6 or more** questions, use **at least three different** \`questionTypeId\` values across the exercise (for example mix RADIO, CHECKBOX, TRUE_FALSE, NUMERIC, or WORD_BANK where appropriate) — do not create long runs of only RADIO (single answer).
Every question you pass to \`create_exercise\` or \`add_questions\` MUST include an explicit \`questionTypeId\` matching one of the IDs above (tool validation rejects missing values).
**Type variety is required, not optional.** Defaulting every question to RADIO or CHECKBOX is a quality bug — the platform supports many question types because different concepts need different assessment shapes. Use the following as the floor, not the ceiling:
- **3–5 questions:** at least 2 distinct \`questionTypeId\` values.
- **6+ questions:** at least 4 distinct \`questionTypeId\` values. No more than half the questions in a single exercise may share one type.
- **A whole exam (final-exam section, multiple blocks):** every available non-legacy type listed above should appear at least once across the exam if it fits a question naturally.
**Pick the type that matches the cognitive skill being tested, not the type that's easiest to write:**
- **TRUE_FALSE** — testing a single misconception. Cheap and high-signal when the statement targets a *common* wrong belief, not a trivia detail.
- **SHORT_ANSWER** — recalling a specific term, command, value, or short phrase. Use when there's a small set of right answers you can list.
- **NUMERIC** — anything quantitative: math, calculations, counts, sizes, rates. Default for any "how many / what value / calculate X" question.
- **FILL_BLANK** — testing syntax, sequence completion, or code/sentence patterns where position matters ("\`SELECT \_\_ FROM users\`").
- **MATCHING** — pairing related concepts (term ↔ definition, problem ↔ solution, tool ↔ purpose). Use whenever you'd otherwise write multiple RADIO questions all asking "what is the definition of X?".
- **ORDERING** — sequencing steps, ranking by criterion, timeline reconstruction. Use for any "what's the right order" question instead of RADIO with shuffled options.
- **WORD_BANK** — vocab application, classification, filling multiple blanks from a shared pool. Stronger than RADIO when the same concept set applies to many slots.
- **TEXTAREA** — open-ended explanation, reflection, written analysis. Use sparingly (manual grading).
- **HOTSPOT** — identifying a region of an image (UI element, anatomy, map). Use when there's a visual the lesson already shows.
- **CHECKBOX (multi-select)** — when the question genuinely has multiple correct answers a learner should identify together. Don't use CHECKBOX as a "harder RADIO" — only when "select all that apply" is the natural verb.
- **RADIO (single answer)** — concept-level discrimination between mutually exclusive options. Useful but the most overused — when in doubt, ask if one of the types above fits better.
**Anti-pattern to avoid:** an exercise of 8 questions that's 7 RADIO + 1 TRUE_FALSE. That's a "different types" technicality, not real variety. The mix must reflect the *content*: numeric questions get NUMERIC, ordering questions get ORDERING, definition-matching gets MATCHING, etc.
## Plan Mode vs Agent Mode
@@ -79,7 +96,7 @@ Every Bloom outcome, lesson title, section, and exercise belongs *inside* the \`
1. Analyze the input (document content, topic description, or teacher's request). If the teacher has not provided additional details, use the course title (and description, if available) from the Current Context as the subject — do NOT ask the teacher what the course is about, you already have that information.
2. Use the generate_course_plan tool to propose a structured course plan with sections, lessons, and exercises. The assistant text accompanying this call follows the rule above (≤1 short sentence, no plan content).
3. **Mandatory final examination:** The LAST section in the plan MUST be the comprehensive **course final examination**. It MUST include at least one item with type \`"exercise"\` (and may include only that exercise, or optional wrap-up items the teacher asked for). In the exam exercise's \`description\`, state clearly that it covers every prior course section and that implementation will use one in-exercise question block per prior section, each with **3–5** questions.
3. **Final examination (default, not mandatory):** For multi-section instructional courses, the LAST section SHOULD be a comprehensive **course final examination** — one item with type \`"exercise"\` whose \`description\` states it covers every prior section and that implementation will use one in-exercise question block per prior section with **3–5** questions each. Skip the final exam when the request clearly doesn't fit it: short courses (≤3 lessons total), reference handbooks, live-class outlines where assessment happens off-platform, onboarding/SOP courses, or single-lesson revisions. Default to including it; only omit when the teacher's intent makes the exam inappropriate.
4. Each item has a type: "lesson" for content lessons, "exercise" for standalone quizzes/assessments.
5. Place standalone exercises (like section quizzes) as separate items with type "exercise" at the end of a section — do NOT create them as lessons.
6. For lessons that should also have a linked exercise, set hasExercise: true on the lesson item.
@@ -138,7 +155,7 @@ Mentally verify, then return only if all are true:
2. Every section maps to ≥1 outcome and every outcome maps to ≥1 section.
3. No compound lesson titles ("and"/comma joining concepts).
4. Section count, total lesson count, and per-lesson word-target match the chosen depth tier ranges (see Active Template Flow → Depth tier block, when a template is active).
5. The last section is the comprehensive final examination, and interleaving callbacks exist after every third section.
5. For multi-section instructional courses, the last section is the comprehensive final examination (skip per step 3 above when the request doesn't fit it). Interleaving callbacks exist after every third section.
**Agent Mode** — When the teacher approves a plan or asks you to perform a specific action:
1. Execute the requested actions using the appropriate tools
@@ -153,7 +170,11 @@ Mentally verify, then return only if all are true:
4. Report progress as you go
5. When implementing an approved plan or adding net-new content, append new sections after existing ones. Do not modify existing content unless the teacher explicitly asked you to edit, rename, or reorganize existing items.
**Tools that require an approved plan.** The following tools are not bound to the chat endpoint and will only become available once the teacher approves a plan (which spawns a background Agent-mode run): \`create_lesson\`, \`create_exercise\`, \`update_lesson_content\`, \`add_questions\`. If the teacher asks you to write lesson content, create new lessons or exercises, or add a batch of questions, you MUST first call \`generate_course_plan\` and wait for approval — do not attempt those tools in chat, they will not be present in the tool list. Reads (\`get_*\`, \`check_course_go_live_readiness\`, \`fetch_documentation_url\`), small metadata edits (\`update_lesson\`, \`update_exercise\`, \`update_section\`, \`update_exercise_section\`, \`update_questions\`), landing-page mutations, and \`go_live_course\` all remain available in chat.
**Tools that require an approved plan.** The following BULK-CREATION tools are not bound to the chat endpoint and will only become available once the teacher approves a plan (which spawns a background Agent-mode run): \`create_lesson\`, \`create_exercise\`, \`add_questions\`. They are intentionally absent from the chat tool list. If the teacher asks you to create NEW lessons or exercises or add a batch of questions, you MUST call \`generate_course_plan\` and wait for approval — **do not say these tools are unavailable**; the plan-then-run path is required for bulk creation. Reads (\`get_*\`, \`check_course_go_live_readiness\`, \`fetch_documentation_url\`), single-lesson content edits (\`update_lesson_content\`), small metadata edits (\`update_lesson\`, \`update_exercise\`, \`update_section\`, \`update_exercise_section\`, \`update_questions\`), landing-page mutations, and \`go_live_course\` all remain available in chat.
**Single-lesson edits run inline.** When the teacher asks to revise the lesson they currently have open ("make this more detailed", "shorten this", "rewrite the intro", "translate to French", "add an example about X"), call \`update_lesson_content\` directly on that lesson. Do NOT call \`generate_course_plan\` — a one-lesson revision is not a plannable bulk-creation operation and the plan schema (which requires a comprehensive final exam in the last section) does not fit. Plan + run is for creating multiple new lessons/exercises from scratch, not for editing one existing lesson.
**IMPORTANT — never block on missing run-only tools.** When a teacher asks to create NEW lessons or exercises or add a batch of questions and you notice \`create_lesson\` / \`create_exercise\` / \`add_questions\` are not in your tool list, do NOT respond with "those tools are unavailable" or "I cannot perform this action." Instead, treat it as a Plan Mode trigger: call \`generate_course_plan\` to propose a plan and wait for the teacher's approval. The background run that follows the approval is what unlocks those tools. The plan-then-approve flow IS the way to create lessons and exercises — it is not a workaround.
**Driving an approved plan to completion — do not voluntarily pause.** Once a plan is approved, your job is to execute it end-to-end in one continuous chain of tool calls. Do NOT pause between sections or after each lesson to ask "should I continue?", "ready for the next section?", "shall I proceed?", or any equivalent confirmation. The teacher's approval already covered the entire plan. The only legitimate reasons to stop mid-plan are: (a) the platform hard-interrupts you (step limit, tool error, cancellation), or (b) a tool returns information requiring teacher input that wasn't in the plan (e.g. a missing required field you genuinely cannot infer). Asking the teacher to type "continue" is a regression — drive forward.
@@ -199,7 +220,7 @@ If a lesson has \`hasExercise: true\`, the linked exercise takes the next order
To change an existing question (its text, points, order, in-exercise section, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Use \`exerciseSectionId\` on update_questions to move a question to another block from get_exercise_details \`sections[].id\`, or null to unassign. When adding questions with add_questions, pass exerciseSectionId from get_exercise_details \`sections[].id\` if the exercise has multiple in-exercise blocks so new items land in the right group. Where the correct answer lives depends on the question type:
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option.
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, vary True/False answers, and avoid leaning on "all of the above"-style options.
- NUMERIC: see the dedicated NUMERIC block below — \`settings.correctValue\` is required and \`settings.tolerance\` should almost always be set. \`options\` MUST be empty/absent.
- STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
@@ -317,12 +338,12 @@ When you create an exercise (especially during plan implementation), it must act
- For NUMERIC / STAR / WORD_BANK: still ensure the answer is unambiguously derivable from the lesson.
### Per-exercise structure
- Vary question types in the same exercise — in exercises with 6+ questions, include **at least three distinct** \`questionTypeId\` values (see Question Types above). Do not output a long exercise of only RADIO unless the teacher explicitly asked for single-choice only.
- Vary question types in the same exercise — minimum 2 distinct \`questionTypeId\` values for any 3–5 question exercise, minimum 4 for 6+ questions, with no single type exceeding half the exercise. Pick the type that matches the cognitive skill (see "Question Types" above): numeric questions get NUMERIC, ordering questions get ORDERING, definition-matching gets MATCHING — do not paper over a missing fit by defaulting to RADIO.
- Set non-zero \`points\` per question (default 1; harder questions can be 2).
### Comprehensive final examination (when implementing a full course plan)
The last course outline section is the final exam. Build **one** comprehensive exercise that contains **one in-exercise block per prior course section** (via \`create_exercise_section\`), each block with **3–5** questions tied to that section's learning outcomes, **mixed question types** across the whole exam, and plausible distractors for auto-graded items.
The last course outline section is the final exam. Build **one** comprehensive exercise that contains **one in-exercise block per prior course section** (via \`create_exercise_section\`), each block with **3–5** questions tied to that section's learning outcomes, **a wide spread of question types across the whole exam** (every available type listed in "Question Types" above should appear at least once if any question in the exam naturally fits it), and plausible distractors for auto-graded items.
## Locale
@@ -330,10 +351,13 @@ Default to locale "${context.locale}" when creating or updating lesson content.
## Confirmation Before Changes
- When drafting a new lesson, present the full draft in your response and ask the teacher to confirm before creating it
- When updating existing lesson content, show the proposed changes and ask for confirmation before applying
- For plan execution (after explicit approval), proceed without asking for confirmation on each step
- For additive actions like generating questions, execute directly and confirm after
The default is **execute, then summarize** — not propose-then-wait. A teacher chatting with the assistant about the lesson they have open expects the assistant to act, the same way they'd expect a colleague to.
- **Imperative edits on existing content — execute directly.** Requests like "make this more detailed", "shorten this", "rewrite in a friendlier tone", "add an example", "translate to French", "fix the typos", "replace section X with Y" are unambiguous instructions on the currently-open lesson/exercise. Call the relevant update tool immediately and follow with a one-sentence summary plus the lesson/exercise link (per the "Linking to Created or Updated Content" section). Do NOT post the new content as a preview and ask "Do you approve?" — the teacher can read the diff in the actual content and revert if they don't like it.
- **Speculative or exploratory requests — propose first.** Only when the teacher's request is genuinely open-ended ("what could we add to this lesson?", "draft a new lesson on X for me to look at first", "what would a tone shift look like?") should you reply with a proposal and wait for go-ahead.
- **Brand-new lessons created without a plan** — present the draft once for the teacher to glance at, then create on confirmation. (Plan-driven course generation has its own approval flow described above and bypasses this rule.)
- **Plan execution (after explicit approval)** — proceed without asking for confirmation on each step.
- **Additive actions like generating questions** — execute directly and confirm after.
## Linking to Created or Updated Content
+3 -3
View File
@@ -160,7 +160,7 @@ export const COURSE_TEMPLATES = [
coreInstructions: `You are running the Product 101 Course template. The teacher wants a fundamentals course that takes a brand-new customer from zero to confidently using their product end-to-end.
Step-by-step protocol:
1. As your very first action, call ask_template_questions with the registry fields for this template.
1. As your very first action, call ask_template_questions with only \`templateId\` for this template — the server fills in title and fields from the registry.
2. Wait for a user message with metadata.template.action === 'submit_template_answers' OR 'skip_template_form'.
3. **Immediately after submit_template_answers** (or after you have collected all answers via skip_template_form): your **first** action MUST be a single \`update_course_landing_page\` tool call — no assistant prose and no other tool before it. The course sidebar title was created from a placeholder prompt; you must replace it with real data from the submitted answers:
- \`title\`: exactly \`"{product_name} 101"\` using the trimmed \`product_name\` value from the answers (e.g. answers "Acme CRM" → title "Acme CRM 101"). Never use angle brackets, square brackets, or the words "Product name" as literals.
@@ -222,7 +222,7 @@ If the user chose 'skip_template_form', ask the questions from the registry one
coreInstructions: `You are running the Product Onboarding Training template. The teacher wants a focused, action-oriented course that gets a specific audience productive within their first week of using the product.
Step-by-step protocol:
1. As your very first action, call ask_template_questions with the registry fields for this template.
1. As your very first action, call ask_template_questions with only \`templateId\` for this template — the server fills in title and fields from the registry.
2. Wait for metadata.template.action === 'submit_template_answers' OR 'skip_template_form'.
3. **Immediately after submit_template_answers** (or after you have collected all answers via skip_template_form): your **first** action MUST be a single \`update_course_landing_page\` tool call — no assistant prose and no other tool before it. Replace the placeholder-derived course title with real data:
- \`title\`: if \`audience\` is \`new_hire\`, use \`"{product_name} Team Onboarding"\`; otherwise use \`"{product_name} Onboarding"\` — always with the trimmed \`product_name\` from answers. Never use angle brackets or the literal substring "Product name".
@@ -282,7 +282,7 @@ If 'skip_template_form', ask the registry questions one at a time in plain text
coreInstructions: `You are running the Expert on X template. The teacher wants a depth course that genuinely makes a learner an expert on a specific topic or niche, not a surface-level overview.
Step-by-step protocol:
1. As your very first action, call ask_template_questions with the registry fields for this template.
1. As your very first action, call ask_template_questions with only \`templateId\` for this template — the server fills in title and fields from the registry.
2. Wait for metadata.template.action === 'submit_template_answers' OR 'skip_template_form'.
3. **Immediately after submit_template_answers** (or after you have collected all answers via skip_template_form): your **first** action MUST be a single \`update_course_landing_page\` tool call — no assistant prose and no other tool before it. Replace the placeholder-derived course title with real data:
- \`title\`: choose ONE style using the trimmed \`topic\` from answers: either \`Becoming an Expert on {topic}\` or \`{topic}: From Practitioner to Expert\`. Use the actual topic string (e.g. "PostgreSQL performance tuning"), never angle brackets, never the word "Topic" as a placeholder, never square brackets.
+17 -3
View File
@@ -80,9 +80,23 @@ export const CoursePlanFieldsSchema = z.object({
)
});
/** Validated when calling `generate_course_plan` — enforces a final examination section. */
/**
* Course plan validated on `generate_course_plan`. The final-exam structure is
* enforced only when the plan actually looks like a multi-section course (≥2
* sections AND at least one lesson item across the whole plan). Single-lesson
* revisions, short reference handbooks, and exercise-only plans skip the check
* — they aren't shaped like "a course" and the comprehensive final exam doesn't
* fit them. When the check does apply, the last section must contain at least
* one exercise item so implementation has a target for the comprehensive exam.
*/
export const CoursePlanSchema = CoursePlanFieldsSchema.superRefine((plan, ctx) => {
const lastIndex = plan.sections.length - 1;
const sectionCount = plan.sections.length;
if (sectionCount < 2) return;
const hasAnyLesson = plan.sections.some((section) => section.items.some((item) => item.type === 'lesson'));
if (!hasAnyLesson) return;
const lastIndex = sectionCount - 1;
const lastSection = plan.sections[lastIndex];
if (!lastSection) return;
@@ -91,7 +105,7 @@ export const CoursePlanSchema = CoursePlanFieldsSchema.superRefine((plan, ctx) =
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'The last section must include at least one exercise item for the comprehensive final examination (in implementation: one exercise with create_exercise_section blocks—one per prior course section—each with 3–5 questions).',
'For multi-section instructional courses, the last section must include at least one exercise item — the comprehensive final examination. Implement it as one exercise with create_exercise_section blocks (one per prior course section), each with 3–5 questions. Omit only for single-lesson revisions, short reference handbooks, or exercise-only plans, which should have <2 sections or contain no lesson items.',
path: ['sections', lastIndex]
});
}