Initial commit: Learning Lab Express

Includes fixes found while reviewing the app:
- Results: open each result by a type+agent+id key, so a video and a
  material draft with the same id no longer open each other's popup
- AuthGate: clear the query cache during render, before pages build their
  queries, and remount pages on organization switch (pages no longer stay
  on skeletons for up to a minute after sign-in or refresh)
- Instruction: keep only failed rules after a partial approve, so retrying
  does not create the successful schedules twice; drop the monthly note
  once count or frequency is edited
- Parser/schedules: never schedule a count below one
- Audio: ignore the end event of a cancelled utterance when changing speed
- Downloads: revoke the object URL later so Safari/Firefox can read it
- Results: refetch SCORM content when any part's revision changes; show a
  failed video as failed instead of "still rendering"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

80
src/express/parse.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { parseInstruction, videoCadence, weeklyTarget, type ContentRule } from './parse'
const content = (text: string) => parseInstruction(text).rules as ContentRule[]
describe('parseInstruction', () => {
it('reads several English content rules with a shared topic', () => {
const { rules, unknown } = parseInstruction('make 2 summaries per day and 1 quiz per week about leadership')
expect(unknown).toEqual([])
expect(rules).toEqual([
{ type: 'content', kind: 'summary', count: 2, per: 'day', topic: 'leadership' },
{ type: 'content', kind: 'quiz', count: 1, per: 'week', topic: 'leadership' },
])
})
it('reads Indonesian rules, number words and research', () => {
const { rules } = parseInstruction('bikin tiga kuis per minggu soal negosiasi, riset internet tiap hari soal sales')
expect(rules[0]).toMatchObject({ type: 'content', kind: 'quiz', count: 3, per: 'week', topic: 'negosiasi' })
expect(rules[1]).toMatchObject({ type: 'research', cadence: 'daily', topic: 'sales', autoApprove: true })
})
it('reads a weekday for weekly research', () => {
const [rule] = parseInstruction('riset tiap hari minggu soal tren HR').rules
expect(rule).toMatchObject({ type: 'research', cadence: 'weekly', weekday: 6 })
})
it('keeps "and" inside a topic', () => {
const [rule] = content('3 flashcards per week about sales and marketing')
expect(rule).toMatchObject({ kind: 'flashcards', count: 3, topic: 'sales and marketing' })
})
it('turns a monthly count into a weekly one and remembers it', () => {
const [rule] = content('8 summaries per month')
expect(rule).toMatchObject({ count: 2, per: 'week', monthly: 8 })
})
it('recognises video, audio and SCORM requests', () => {
const rules = content('1 video per week, 2 podcasts per week and 1 SCORM module per week about onboarding')
expect(rules.map(r => [r.kind, r.count, r.per, r.topic])).toEqual([
['video', 1, 'week', 'onboarding'],
['audio', 2, 'week', 'onboarding'],
['scorm', 1, 'week', 'onboarding'],
])
})
it('treats a SCORM module with a quiz as SCORM', () => {
expect(content('1 modul scorm dengan kuis per minggu')[0].kind).toBe('scorm')
})
it('reports clauses it did not understand', () => {
const { rules, unknown } = parseInstruction('2 summaries per day. hello there')
expect(rules).toHaveLength(1)
expect(unknown).toEqual(['hello there'])
})
})
describe('videoCadence', () => {
const video = (count: number, per: 'day' | 'week'): ContentRule => ({ type: 'content', kind: 'video', count, per, topic: '' })
it('maps one per day and one per week to the engine cadences', () => {
expect(videoCadence(video(1, 'day'))).toBe('daily')
expect(videoCadence(video(7, 'week'))).toBe('daily')
expect(videoCadence(video(1, 'week'))).toBe('weekly')
})
it('has no cadence for other counts', () => {
expect(videoCadence(video(3, 'week'))).toBeNull()
expect(videoCadence(video(2, 'day'))).toBeNull()
})
it('counts daily rules as seven a week', () => {
expect(weeklyTarget(video(2, 'day'))).toBe(14)
})
})
describe('counts', () => {
it('never parses a count below one', () => {
expect(parseInstruction('buat 0 ringkasan per minggu').rules[0]).toMatchObject({ kind: 'summary', count: 1 })
})
})

179
src/express/parse.ts Normal file
View File

@@ -0,0 +1,179 @@
// Turns an everyday instruction (English or Indonesian) into schedule rules, e.g.
// "make 2 summaries per day and 1 quiz per week about leadership, research the internet daily about sales"
// "bikin 2 summary per hari dan 1 kuis per minggu soal leadership, riset internet tiap hari soal sales".
// Deterministic on purpose: the user always sees and can correct the result before anything is created.
import { t, type Key } from '../i18n'
export type ContentKind = 'summary' | 'quiz' | 'flashcards' | 'roleplay' | 'simulator' | 'slide_deck' | 'video' | 'audio' | 'scorm'
export const KINDS: ContentKind[] = ['summary', 'quiz', 'flashcards', 'roleplay', 'simulator', 'slide_deck', 'video', 'audio', 'scorm']
// Every kind runs on the engine's agents, in one of two ways:
// - recurring content production (content_goal): the six Compose kinds, plus audio (a narration
// script, played and downloaded in Express) and SCORM (a lesson + quiz pair packaged in Express);
// - one scheduled run per day or week (run_schedule): video, since the engine renders one video per run.
export const SCHEDULABLE: ReadonlySet<ContentKind> = new Set(KINDS)
export const kindLabel = (kind: ContentKind) => t(`kind.${kind}` as Key)
// Checked in order; the first match wins, so multi-word phrases come before their parts.
const KIND_WORDS: [ContentKind, RegExp][] = [
// SCORM first: a SCORM module includes a quiz, so "SCORM module with a quiz" is still SCORM.
['scorm', /\b(scorm|skorm|skong|e-?learning)\b/],
['flashcards', /\b(flash ?cards?|kartu (belajar|hafalan))\b/],
['quiz', /\b(kuis|quiz|quizzes|soal latihan|tes|tests?|ujian|exams?)\b/],
['roleplay', /\b(role ?-?plays?|bermain peran)\b/],
['simulator', /\b(simulasi|simulations?|simulators?|skenario|scenarios?)\b/],
['slide_deck', /\b(slides?|presentasi|presentations?|decks?|ppt)\b/],
['video', /\b(videos?)\b/],
['audio', /\b(audio|podcasts?|siniar|narasi|narrations?)\b/],
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
]
const NUMBER_WORDS: Record<string, number> = {
satu: 1, se: 1, sebuah: 1, dua: 2, tiga: 3, empat: 4, lima: 5, enam: 6, tujuh: 7, delapan: 8, sembilan: 9, sepuluh: 10,
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
}
// Monday = 0, as the engine counts.
const WEEKDAYS: [RegExp, number][] = [
[/\b(senin|monday)\b/, 0], [/\b(selasa|tuesday)\b/, 1], [/\b(rabu|wednesday)\b/, 2], [/\b(kamis|thursday)\b/, 3],
[/\b(jum'?at|friday)\b/, 4], [/\b(sabtu|saturday)\b/, 5],
// "minggu" is both Sunday and "week", so Indonesian Sunday needs "hari minggu".
[/\b(hari minggu|sunday)\b/, 6],
]
export type Per = 'day' | 'week'
export interface ContentRule {
type: 'content'
kind: ContentKind
count: number
per: Per
topic: string
/** The monthly count the instruction asked for, when it had to be turned into a weekly one. */
monthly?: number
}
export interface ResearchRule {
type: 'research'
cadence: 'daily' | 'weekly'
weekday: number
topic: string
autoApprove: boolean
}
export type Rule = ContentRule | ResearchRule
export interface ParseResult {
rules: Rule[]
/** Clauses that said something but matched no rule, so the user can rephrase them. */
unknown: string[]
}
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
function topicOf(clause: string): string {
return clause.match(TOPIC)?.[1].replace(FREQUENCY_TAIL, '').replace(/[\s,]+$/, '').trim() ?? ''
}
function countOf(clause: string, kindAt: number): number {
const before = clause.slice(0, kindAt)
const digits = before.match(/(\d+)\s*(?:buah|x|kali|new)?\s*$/)
// "0" schedules nothing; the preview shows 1, which the user can still change.
if (digits) return Math.max(1, Number(digits[1]))
const word = before.match(/(\p{L}+)\s*(?:buah|new)?\s*$/u)?.[1]
return (word && NUMBER_WORDS[word]) || 1
}
function perOf(clause: string): Per | 'month' {
if (/\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/.test(clause)) return 'day'
if (/\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/.test(clause)) return 'month'
return 'week'
}
function weekdayOf(clause: string): number | null {
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
return null
}
const CONNECTORS = 'dan|lalu|serta|terus|kemudian|juga|and|then|also|plus'
// A connector or comma starts a new rule only when a new request follows; "about sales and marketing" stays one topic.
const NEXT_REQUEST = String.raw`(?=(?:\d|satu|dua|tiga|empat|lima|enam|tujuh|delapan|sembilan|sepuluh|se(?:buah)?\s|one|two|three|four|five|six|seven|eight|nine|ten|an?\s|riset|research|bikin|buat|cari|telusuri|pantau|make|create|generate|search|monitor|tiap|setiap|every|each))`
const SPLIT = new RegExp(String.raw`[\n.;]+|\s*,\s*(?:(?:${CONNECTORS})\s+)?${NEXT_REQUEST}|\s+(?:${CONNECTORS})\s+${NEXT_REQUEST}`)
function parseClause(clause: string): Rule | null {
if (/\b(riset|research|telusuri|cari(?:kan)?|pantau|look up|search|monitor)\b/.test(clause)) {
const per = perOf(clause)
const day = weekdayOf(clause)
const cadence = per === 'day' && day === null ? 'daily' : 'weekly'
return { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true }
}
for (const [kind, re] of KIND_WORDS) {
const m = re.exec(clause)
if (!m) continue
const count = countOf(clause, m.index)
const per = perOf(clause)
if (per === 'month') {
return { type: 'content', kind, count: Math.max(1, Math.round(count / 4)), per: 'week', topic: topicOf(clause), monthly: count }
}
return { type: 'content', kind, count, per, topic: topicOf(clause) }
}
return null
}
export function parseInstruction(text: string): ParseResult {
const clauses = text
.toLowerCase()
.replace(/[“”"]/g, '')
.split(SPLIT)
.map(s => s.trim())
.filter(Boolean)
const rules: Rule[] = []
const unknown: string[] = []
let shared = ''
for (const clause of clauses) {
const rule = parseClause(clause)
if (rule) rules.push(rule)
// A clause that only names a topic ("about leadership") applies to the rules around it.
else if (topicOf(clause) || /^(soal|tentang|topik|about|topic)\b/.test(clause)) shared = topicOf(clause) || clause.replace(/^\S+\s*/, '')
else if (!/^(tolong|bikin|buat|buatkan|ya|please|make|create|ok)$/.test(clause)) unknown.push(clause)
}
// One topic mentioned anywhere also covers rules that did not name their own.
const topics = new Set(rules.map(r => r.topic).filter(Boolean))
const fallback = shared || (topics.size === 1 ? [...topics][0] : '')
for (const r of rules) if (!r.topic) r.topic = fallback
return { rules, unknown }
}
export function weeklyTarget(rule: ContentRule): number {
return rule.per === 'day' ? rule.count * 7 : rule.count
}
/** A video schedule is one engine run per day or per week; other counts have no matching cadence. */
export function videoCadence(rule: ContentRule): 'daily' | 'weekly' | null {
const target = weeklyTarget(rule)
return target === 7 ? 'daily' : target === 1 ? 'weekly' : null
}
export const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key)
export function describeRule(rule: Rule): string {
const about = rule.topic ? t('rule.about', { topic: rule.topic }) : ''
if (rule.type === 'research') {
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) })
return t('rule.research', { when, about })
}
return t('rule.content', {
kind: kindLabel(rule.kind),
count: rule.count,
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
about: about || t('rule.wholeBrain'),
})
}
export const monthNote = (rule: ContentRule) =>
rule.monthly ? t('rule.monthNote', { count: rule.monthly, weekly: rule.count }) : null

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { correctIndex, questionsOf, toMarkdown, type Material } from './results'
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
describe('correctIndex', () => {
const options = ['Listen first', 'Interrupt', 'Leave']
it('finds the answer as option text, letter or index', () => {
expect(correctIndex(options, 'listen first')).toBe(0)
expect(correctIndex(options, 'B')).toBe(1)
expect(correctIndex(options, 'C. Leave')).toBe(2)
expect(correctIndex(options, '1')).toBe(1)
})
it('returns -1 for an answer outside the options', () => {
expect(correctIndex(options, 'D')).toBe(-1)
expect(correctIndex([], 'An essay answer')).toBe(-1)
})
})
describe('questionsOf', () => {
it('reads the engine’s question shapes', () => {
const [q] = questionsOf({ questions: [{ text: 'Q', choices: [{ text: 'x' }, { label: 'y' }], correct_answer: 'y', explanation: 'because' }] })
expect(q).toEqual({ question: 'Q', options: ['x', 'y'], answer: 'y', explanation: 'because', correct: 1 })
})
})
describe('toMarkdown', () => {
const material = (kind: Material['kind']): Material => ({
id: 1, agentId: 1, kind, status: 'ready', revision: 1, parts: [], planTitle: 'Onboarding', composeSetId: null,
sources: ['Handbook'], createdAt: '2026-10-01T00:00:00Z', rule: { type: 'content', kind, count: 1, per: 'week', topic: '' },
})
it('writes a SCORM module as the lesson followed by its quiz', () => {
const md = toMarkdown(material('scorm'), { title: 'Onboarding', text: 'Welcome.', questions: [{ question: 'Q?', options: ['a', 'b'], answer: 'b' }] })
expect(md).toMatch(/^# Onboarding\n\nWelcome\.\n\n## Quiz\n\n1\. Q\?\n {3}- a\n {3}- b\n/)
expect(md).toContain('Sources: Handbook')
})
it('writes an audio lesson as its script', () => {
expect(toMarkdown(material('audio'), { text: 'Hello team.' })).toBe('# Onboarding\n\nHello team.\n\n---\nSources: Handbook\n')
})
})
describe('audio script helpers', () => {
it('splits a script into sentences without Markdown', () => {
expect(sentencesOf('# Intro\nHello **team**. Ready? Let’s go!\n\n- one')).toEqual(['Intro', 'Hello team.', 'Ready?', 'Let’s go!', 'one'])
})
it('guesses the script language for the voice', () => {
expect(scriptLang('Ini adalah pelajaran yang penting untuk kita semua dan tim.')).toBe('id')
expect(scriptLang('This is the lesson that we will share with the team.')).toBe('en')
})
})

331
src/express/results.ts Normal file
View File

@@ -0,0 +1,331 @@
// Results = everything the Express agents produced: Compose materials (material-drafts, titled by the
// content plan item that ordered them), rendered videos (video-drafts) and web sources found by
// research (source-drafts). Audio and SCORM are Express kinds built from Compose outputs: an audio
// lesson is a narration summary, a SCORM module is the summary + quiz pair one plan item produced.
import { request } from '../api/client'
import { t } from '../i18n'
import { kindLabel, type ContentKind, type Rule } from './parse'
import { expressAgents, PLAN_ACTIVE, productionStatus, type Schedule } from './schedules'
const AGENTS = '/organizations/current/agents'
export type MaterialStatus = 'preparing' | 'generating' | 'ready' | 'failed' | 'accepted' | 'rejected'
interface RawMaterial {
id: number
agent_id: number
run_id: number
output_kind: ContentKind
status: MaterialStatus
revision: number
compose_set_id: number | null
sources: { document_id: number; title: string }[]
created_at: string
}
/** One engine draft behind a result; a SCORM module has two (lesson and quiz). */
export interface Part { id: number; revision: number; kind: ContentKind; status: MaterialStatus }
export interface Material {
id: number
agentId: number
/** What the user asked for, which for audio and SCORM is not the engine's output kind. */
kind: ContentKind
status: MaterialStatus
revision: number
parts: Part[]
planTitle: string | null
composeSetId: number | null
sources: string[]
createdAt: string
rule: Rule
video?: { url: string | null; progress: number; credits: number | null }
}
// Labels are resolved at render time so they follow the UI language.
export const titleOf = (m: Material) => m.planTitle ?? [kindLabel(m.kind), m.rule.topic].filter(Boolean).join(' · ')
// Ids come from separate engine tables (material drafts, video drafts, plan items) and repeat across them.
export const materialKey = (m: Material) => `${m.kind === 'video' ? 'video' : 'material'}-${m.agentId}-${m.id}`
export type MaterialContent = Record<string, unknown>
export interface FoundSource {
id: number
agentId: number
title: string
url: string | null
status: 'pending' | 'accepted' | 'rejected' | string
revision: number
preview: string
documentId: number | null
createdAt: string
rule: Rule
}
interface RawSource {
id: number
agent_id: number
title: string
source_url: string | null
status: string
revision: number
preview: string
document_id: number | null
captured_at: string
}
// The least finished part decides: a module is ready only when both its lesson and quiz are.
const ORDER: MaterialStatus[] = ['failed', 'rejected', 'preparing', 'generating', 'ready', 'accepted']
const combined = (parts: Part[]) => ORDER.find(s => parts.some(p => p.status === s)) ?? 'preparing'
async function materialsOf(schedule: Schedule): Promise<Material[]> {
if (schedule.rule.type === 'content' && schedule.rule.kind === 'video') return videosOf(schedule)
const { agent, rule } = schedule
const kind = rule.type === 'content' ? rule.kind : 'summary'
const [drafts, status] = await Promise.all([
request<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`),
productionStatus(agent.id).catch(() => null),
])
const titles = new Map<number, string>()
for (const plan of status?.plans ?? []) {
for (const item of plan.items ?? []) if (item.run_id) titles.set(item.run_id, item.title)
}
// One run makes one result; its drafts are its parts (one, or the lesson + quiz of a SCORM module).
const byRun = new Map<number, RawMaterial[]>()
for (const d of drafts) byRun.set(d.run_id, [...(byRun.get(d.run_id) ?? []), d])
const made = [...byRun.values()].map((group): Material => {
// The lesson comes first: it carries the title, sources and Compose set of the whole module.
group.sort((a, b) => Number(b.output_kind === 'summary') - Number(a.output_kind === 'summary'))
const [d] = group
const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status }))
return {
id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind,
status: combined(parts), revision: d.revision, parts,
planTitle: titles.get(d.run_id) ?? null,
composeSetId: d.compose_set_id, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
}
})
// Planned pieces have no draft until generation starts; show them as in progress so a running
// production is visible here from the moment the topics are chosen.
const started = new Set(drafts.map(d => d.run_id))
const planned = (status?.plans ?? []).filter(p => PLAN_ACTIVE.has(p.status)).flatMap(p => p.items)
.filter(i => ['proposed', 'approved', 'queued', 'generating'].includes(i.status) && !(i.run_id && started.has(i.run_id)))
.map((i): Material => ({
id: -i.id, agentId: agent.id, kind, status: 'preparing', revision: 0, parts: [],
planTitle: i.title, composeSetId: null, sources: [], createdAt: new Date().toISOString(), rule,
}))
return [...planned, ...made]
}
interface RawVideo {
id: number
run_id: number
status: string
title: string | null
progress_percentage: number
video_url: string | null
sources: { document_id: number; title: string }[]
actual_credits: number | null
created_at: string
}
// Video drafts move through script, storyboard, clips and render before they are ready to review.
function videoStatus(s: string): MaterialStatus {
if (s === 'ready' || s === 'accepted' || s === 'rejected') return s
if (s === 'failed' || s === 'cancelled') return 'failed'
return 'generating'
}
async function videosOf({ agent, rule }: Schedule): Promise<Material[]> {
const drafts = await request<RawVideo[]>(`${AGENTS}/${agent.id}/video-drafts`)
return drafts.map(d => {
const status = videoStatus(d.status)
return {
id: d.id, agentId: agent.id, kind: 'video', status, revision: 0,
parts: [{ id: d.id, revision: 0, kind: 'video', status }],
planTitle: d.title, composeSetId: null, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
video: { url: d.video_url, progress: d.progress_percentage, credits: d.actual_credits },
}
})
}
async function sourcesOf({ agent, rule }: Schedule): Promise<FoundSource[]> {
const drafts = await request<RawSource[]>(`${AGENTS}/${agent.id}/source-drafts`)
return drafts.map(d => ({
id: d.id, agentId: agent.id, title: d.title, url: d.source_url, status: d.status, revision: d.revision,
preview: d.preview, documentId: d.document_id, createdAt: d.captured_at, rule,
}))
}
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
const schedules = await expressAgents({ archived: true })
const [materials, sources] = await Promise.all([
Promise.all(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
Promise.all(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
])
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
return { materials: materials.flat().sort(newest), sources: sources.flat().sort(newest) }
}
const draftContent = (m: Material, id: number) =>
request<{ content?: MaterialContent; output_id?: number }>(`${AGENTS}/${m.agentId}/material-drafts/${id}`)
// outputId is the generated Compose output (e.g. the SlideDeck id), which owns the file exports.
// A SCORM module merges its parts: the lesson's title and text with the quiz's questions.
export async function materialContent(m: Material): Promise<{ content: MaterialContent | null; outputId: number | null }> {
if (m.kind === 'video') return { content: { title: m.planTitle ?? '' }, outputId: null }
const [first, ...rest] = await Promise.all(m.parts.map(p => draftContent(m, p.id)))
if (!first?.content) return { content: null, outputId: null }
const content = { ...first.content }
for (const r of rest) if (r.content?.questions) content.questions = r.content.questions
return { content, outputId: first.output_id ?? null }
}
// The engine renders slide decks to PPTX itself; other kinds have no file export there.
export const exportPptx = (deckId: number) => request<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
// Every part waiting for review gets the same decision, so a SCORM module is approved as one.
export async function decideMaterial(m: Material, decision: 'accept' | 'reject') {
for (const p of m.parts.filter(p => p.status === 'ready')) {
if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } })
else await request(`${AGENTS}/${m.agentId}/material-drafts/${p.id}/decision`, { method: 'POST', json: { expected_revision: p.revision, decision } })
}
}
// Accepting a source only adds it to the Library; its knowledge build is confirmed on the Brain page.
export const reviewSource = (s: FoundSource, decision: 'accept' | 'reject') =>
request(`${AGENTS}/${s.agentId}/source-drafts/${s.id}/review`, { method: 'POST', json: { expected_revision: s.revision, decision } })
export const isWorking = (m: Material) => m.status === 'preparing' || m.status === 'generating'
// ── Export ──
const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v])
export interface Question {
question: string
options: string[]
answer: string
explanation: string
/** Index of the correct option, or -1 when the answer is not one of the options (an essay). */
correct: number
}
// The engine gives the answer as the option text, its letter ("B", "B. …") or its index.
export function correctIndex(options: string[], answer: string): number {
const exact = options.findIndex(o => o.trim().toLowerCase() === answer.trim().toLowerCase())
if (exact >= 0) return exact
const letter = answer.trim().match(/^([A-Ha-h])(?:[.)\s:]|$)/)?.[1]
if (letter) {
const i = letter.toUpperCase().charCodeAt(0) - 65
if (i < options.length) return i
}
const index = /^\d+$/.test(answer.trim()) ? Number(answer) : -1
return index >= 0 && index < options.length ? index : -1
}
export function questionsOf(content: MaterialContent): Question[] {
return list(content.questions).map(raw => {
const q = (raw ?? {}) as Record<string, unknown>
const options = list(q.options ?? q.choices).map(o => (typeof o === 'object' && o ? str((o as Record<string, unknown>).text ?? (o as Record<string, unknown>).label) : str(o)))
const answer = str(q.correct_answer ?? q.answer)
return { question: str(q.question ?? q.text ?? q.prompt), options, answer, explanation: str(q.explanation), correct: correctIndex(options, answer) }
})
}
export interface Slide { title: string; bullets: string[]; notes: string }
export function slidesOf(content: MaterialContent): Slide[] {
return list(content.slides).map(raw => {
const s = (raw ?? {}) as Record<string, unknown>
const c = ((s.content ?? s) as Record<string, unknown>) ?? {}
return { title: str(c.title ?? s.title), bullets: list(c.bullets).map(str), notes: str(s.speaker_notes ?? s.notes) }
})
}
export const textOf = str
export const listOf = list
export function toMarkdown(m: Material, content: MaterialContent): string {
const out = [`# ${str(content.title) || titleOf(m)}`, '']
if (m.kind === 'scorm') out.push(str(content.text), '', `## ${t('res.quiz')}`, '')
if (m.kind === 'summary' || m.kind === 'audio') out.push(str(content.text))
else if (m.kind === 'quiz' || m.kind === 'scorm') {
questionsOf(content).forEach((q, i) => {
out.push(`${i + 1}. ${q.question}`, ...q.options.map(o => ` - ${o}`))
if (q.answer) out.push(` ${t('res.answer')}: ${q.answer}`)
if (q.explanation) out.push(` ${q.explanation}`)
out.push('')
})
} else if (m.kind === 'flashcards') {
for (const c of list(content.cards) as Record<string, unknown>[]) out.push(`**${str(c.front)}**`, str(c.back), '')
} else if (m.kind === 'slide_deck') {
if (content.description) out.push(str(content.description), '')
slidesOf(content).forEach((s, i) => out.push(`## ${i + 1}. ${s.title}`, ...s.bullets.map(b => `- ${b}`), ''))
} else {
for (const [key, value] of Object.entries(content)) {
if (key === 'title') continue
out.push(`## ${key.replace(/_/g, ' ')}`, ...list(value).map(v => (Array.isArray(value) ? `- ${str(v)}` : str(v))), '')
}
}
if (m.sources.length) out.push('', '---', `${t('res.sources')}: ${m.sources.join(', ')}`)
return out.join('\n').trim() + '\n'
}
export function download(name: string, data: string | Blob) {
const url = URL.createObjectURL(typeof data === 'string' ? new Blob([data], { type: 'text/markdown;charset=utf-8' }) : data)
const a = Object.assign(document.createElement('a'), { href: url, download: name })
a.click()
// Revoked later: Safari and Firefox may not have started reading the file when click() returns.
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
export const fileName = (m: Material, ext: 'md' | 'pptx' | 'txt' | 'zip' | 'mp4') =>
`${titleOf(m).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || m.kind}.${ext}`
// PDF without a PDF library: print a standalone page from a hidden iframe, so the browser's own
// "Save as PDF" lays out the text (real, selectable text with proper fonts and page breaks).
export function printPdf(title: string, bodyHtml: string) {
const frame = Object.assign(document.createElement('iframe'), { title: 'print' })
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0'
document.body.append(frame)
const doc = frame.contentDocument!
doc.open()
doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>${PRINT_CSS}</style></head><body>${bodyHtml}</body></html>`)
doc.close()
const win = frame.contentWindow!
// Remove the frame once the print dialog is done; the timeout covers browsers without afterprint.
const cleanup = () => setTimeout(() => frame.remove(), 500)
win.addEventListener('afterprint', cleanup, { once: true })
setTimeout(() => { win.focus(); win.print(); cleanup() }, 100)
}
const escapeHtml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
const PRINT_CSS = `
@page{size:A4;margin:18mm 16mm}
*{box-sizing:border-box}
body{font:11pt/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1d2a3b;margin:0}
header{border-bottom:2px solid #2563eb;padding-bottom:10px;margin-bottom:18px}
header small{display:block;font-size:8.5pt;letter-spacing:.08em;text-transform:uppercase;color:#2563eb;font-weight:600}
header p{margin:6px 0 0;font-size:9pt;color:#6b7a90}
h1{font-size:20pt;line-height:1.3;margin:4px 0 0}
article>h1{display:none}
h2{font-size:13pt;margin:18px 0 6px;break-after:avoid}
p{margin:0 0 9px}
ul,ol{margin:0 0 10px;padding-left:20px}
li{margin-bottom:4px}
.quiz-list>li{margin-bottom:14px;break-inside:avoid}
.quiz-list li.correct{font-weight:600;color:#127a55}
.quiz-list li.correct:after{content:" ✓"}
.muted{color:#6b7a90;font-size:10pt}
.flashcards{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.flashcard{border:1px solid #d9e2ef;border-radius:8px;padding:10px 12px;break-inside:avoid;display:flex;flex-direction:column;gap:6px}
.flashcard span{color:#4d5d73}
.slide-preview{border:1px solid #d9e2ef;border-radius:8px;padding:12px 14px;margin-bottom:10px;break-inside:avoid}
.slide-preview small{font-size:8pt;letter-spacing:.08em;text-transform:uppercase;color:#8a97a8}
.slide-preview h2{margin-top:2px}
footer{margin-top:24px;padding-top:8px;border-top:1px solid #e3e9f2;font-size:8.5pt;color:#8a97a8}
`

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding'): ContentRule =>
({ type: 'content', kind, count, per, topic })
// What the engine sends back for an agent created from configFor.
function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent {
const c = config as Record<string, unknown>
return {
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', auto_approve_sources: false,
name: c.name as string, goal: c.goal as string,
enabled_tools: c.enabled_tools as string[],
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
content_goal: (c.content_goal as ExpressAgent['content_goal']) ?? null,
video_options: (c.video_options as ExpressAgent['video_options']) ?? null,
run_schedule: (c.run_schedule as ExpressAgent['run_schedule']) ?? null,
run_budget: c.run_budget as number, monthly_budget: c.monthly_budget as number,
}
}
describe('configFor', () => {
it('makes an audio schedule a narration summary', () => {
const config = configFor(rule('audio')) as Record<string, unknown>
expect(config.material_outputs).toEqual([{ kind: 'summary', options: {} }])
expect(config.instructions).toMatch(/narration script/)
expect(config.name).toBe('Express · 2 audio per week · onboarding')
})
it('makes a SCORM schedule a lesson + quiz pair', () => {
const config = configFor(rule('scorm')) as Record<string, unknown>
expect((config.material_outputs as { kind: string }[]).map(o => o.kind)).toEqual(['summary', 'quiz'])
})
it('makes a video schedule a run-scheduled Video Studio agent', () => {
const config = configFor(rule('video', 1, 'day'), 100) as Record<string, unknown>
expect(config.enabled_tools).toContain('studio.create_video')
expect(config).not.toHaveProperty('content_goal')
expect(config.run_schedule).toMatchObject({ cadence: 'daily' })
expect(config.video_options).toMatchObject({ max_scenes: VIDEO.scenes, max_duration_seconds: VIDEO.seconds })
expect(config.run_budget).toBe(110)
expect(config.monthly_budget).toBe(110 * 31)
})
it('keeps budgets ordered run ≤ weekly ≤ monthly', () => {
for (const kind of ['summary', 'quiz', 'audio', 'scorm'] as const) {
for (const count of [1, 5, 50]) {
const b = budgetFor(rule(kind, count))
expect(b.run).toBeLessThanOrEqual(b.weekly!)
expect(b.weekly!).toBeLessThanOrEqual(b.monthly)
}
}
})
})
describe('ruleOf', () => {
it.each([
rule('summary', 3),
rule('audio', 2),
rule('scorm', 1),
rule('quiz', 1, 'day'),
rule('video', 1, 'week'),
rule('video', 1, 'day'),
])('reads back the rule a schedule was created from: $count $kind per $per', r => {
expect(ruleOf(agentFrom(configFor(r, 50)))).toEqual(r)
})
it('reads back a research rule', () => {
const r: ResearchRule = { type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: true }
const agent = { ...agentFrom(configFor(r)), auto_approve_sources: true }
expect(ruleOf(agent)).toEqual(r)
})
})
describe('canSchedule', () => {
it('accepts every kind within limits', () => {
expect(canSchedule(rule('audio', 5))).toBe(true)
expect(canSchedule(rule('scorm', 2))).toBe(true)
expect(canSchedule(rule('video', 1))).toBe(true)
})
it('rejects video counts without a cadence and production over 50 a week', () => {
expect(canSchedule(rule('video', 3))).toBe(false)
expect(canSchedule(rule('summary', 8, 'day'))).toBe(false)
expect(canSchedule(rule('summary', 0))).toBe(false)
})
})

316
src/express/schedules.ts Normal file
View File

@@ -0,0 +1,316 @@
// Each Express schedule is one engine agent. Content rules use the engine's recurring production
// (content_goal: weekly plans with novelty checks, started automatically); research and video rules
// use run_schedule (one run per day or week; a video run renders one video).
import { opKey, request } from '../api/client'
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
import { getLang } from '../i18n'
import { KINDS, SCHEDULABLE, videoCadence, weeklyTarget, type ContentKind, type ContentRule, type Per, type ResearchRule, type Rule } from './parse'
const AGENTS = '/organizations/current/agents'
export const PREFIX = 'Express · '
const TIMEZONE = 'Asia/Jakarta'
export interface ContentGoal {
enabled: boolean
weekly_target: number
execution_mode: 'automatic' | 'review'
production_cadence: 'spread' | 'weekly' | 'manual'
production_hour: number
production_minute: number
production_weekday: number
weekly_budget: number
}
export interface VideoOptions {
max_scenes: number
max_duration_seconds: number
art_style: 'realistic' | 'cartoon' | 'anime' | 'watercolor' | '3d'
language: 'English' | 'Bahasa Indonesia'
}
export interface ExpressAgent extends Agent {
next_run_at: string | null
enabled_tools: string[]
material_outputs: { kind: ContentKind; options: Record<string, unknown> }[]
content_goal: ContentGoal | null
video_options: VideoOptions | null
auto_approve_sources: boolean
run_budget: number
monthly_budget: number
created_at: string
}
export interface Schedule {
agent: ExpressAgent
rule: Rule
active: boolean
}
// The per-day/per-week wording is not part of the engine config, so it travels in the agent name.
// Names stay in English whatever the UI language, so they parse the same for every viewer.
function scheduleName(rule: Rule): string {
const topic = rule.topic ? ` · ${rule.topic}` : ''
if (rule.type === 'research') return `${PREFIX}Research ${rule.cadence}${topic}`.slice(0, 120)
return `${PREFIX}${rule.count} ${rule.kind} per ${rule.per}${topic}`.slice(0, 120)
}
const isKind = (k: string | undefined): k is ContentKind => KINDS.includes(k as ContentKind)
export function ruleOf(agent: ExpressAgent): Rule {
const topic = agent.name.split(' · ').slice(2).join(' · ')
if (agent.enabled_tools.includes('studio.create_video')) {
const daily = agent.run_schedule?.cadence === 'daily'
return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic }
}
if (agent.content_goal?.enabled && agent.material_outputs[0]) {
// " per hari" comes from schedules created before names were in English.
const per: Per = / per (day|hari)\b/.test(agent.name) ? 'day' : 'week'
const target = agent.content_goal.weekly_target
// Audio and SCORM are Express kinds made of Compose outputs, so the name carries the kind.
const named = agent.name.match(/^Express · \d+ (\w+) per /)?.[1]
const kind = isKind(named) ? named : agent.material_outputs[0].kind
return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic }
}
const s = agent.run_schedule ?? { cadence: 'off' }
return { type: 'research', cadence: s.cadence === 'weekly' ? 'weekly' : 'daily', weekday: s.weekday ?? 0, topic, autoApprove: agent.auto_approve_sources }
}
// Archived schedules are gone from the Schedule page, but what they produced still belongs on Results.
export async function expressAgents({ archived = false } = {}): Promise<Schedule[]> {
const agents = await request<ExpressAgent[]>(AGENTS)
return agents
.filter(a => a.name.startsWith(PREFIX) && (archived || a.status !== 'archived'))
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map(agent => ({ agent, rule: ruleOf(agent), active: agent.status === 'active' }))
}
export const listSchedules = () => expressAgents()
/** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */
export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video'
// Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene).
export const VIDEO = { scenes: 4, seconds: 40 }
export interface VideoQuote { max_scenes: number; max_total_seconds: number; max_credits: number }
/** The current price ceiling of one video; the engine pins and rechecks it when a run starts. */
export const videoQuote = () =>
request<VideoQuote>(`${AGENTS}/video-quote?scenes=${VIDEO.scenes}&duration_seconds=${VIDEO.seconds}`)
// Credit limits. The engine requires run ≤ weekly ≤ monthly; a run is one planned item, one research
// pass, or one video. A SCORM item is two Compose outputs (lesson + quiz), so it gets twice the room.
export function budgetFor(rule: Rule, videoCredits = 0) {
if (rule.type === 'research') {
const run = 50
return { run, weekly: null, monthly: rule.cadence === 'daily' ? run * 31 : run * 5 }
}
if (rule.kind === 'video') {
// A little headroom over today's quote, so a small price change does not stop the schedule.
const run = Math.max(1, Math.ceil((videoCredits * 11) / 10))
return { run, weekly: null, monthly: videoCadence(rule) === 'daily' ? run * 31 : run * 5 }
}
const perItem = rule.kind === 'scorm' ? 40 : 20
const weekly = Math.max(rule.kind === 'scorm' ? 120 : 60, weeklyTarget(rule) * perItem)
return { run: Math.min(rule.kind === 'scorm' ? 60 : 30, weekly), weekly, monthly: weekly * 5 }
}
const KIND_TEXT: Record<ContentKind, string> = {
summary: 'summaries', quiz: 'quizzes', flashcards: 'flashcard sets', roleplay: 'roleplay scenarios',
simulator: 'simulations', slide_deck: 'slide decks', video: 'short explainer videos', audio: 'audio lessons',
scorm: 'e-learning modules (a lesson with a quiz that checks it)',
}
// What each Express kind asks of the engine's Compose outputs.
const KIND_BRIEF: Partial<Record<ContentKind, string>> = {
audio: 'Each summary is the narration script of an audio lesson of about 3 to 5 minutes. Write it to be read aloud: '
+ 'conversational, short sentences, a friendly opening and a short recap at the end. No headings, bullet points, tables or Markdown.',
scorm: 'Each lesson summary and its quiz form one e-learning module: the quiz checks exactly what the summary teaches.',
video: 'Each video explains one practical idea from the Brain in plain language for employees.',
}
function outputsFor(kind: ContentKind): ExpressAgent['material_outputs'] {
if (kind === 'audio') return [{ kind: 'summary', options: {} }]
if (kind === 'scorm') return [{ kind: 'summary', options: {} }, { kind: 'quiz', options: { question_count: 10, question_type: 'multiple_choice_only' } }]
return [{ kind, options: {} }]
}
// The agent's brief is in English; the content language follows the UI language at creation time.
function goalText(rule: Rule): string {
const about = rule.topic ? ` about ${rule.topic}` : ''
if (rule.type === 'research') {
return `Research the public internet${about || ' for learning topics relevant to the L&D team'} and add new, relevant public sources to the team's Brain.`
}
return `Create new learning ${KIND_TEXT[rule.kind]}${about} from the L&D team's Brain, without repeating content that already exists.`
}
function instructionsFor(rule: Rule): string {
const language = getLang() === 'id' ? 'Write all content in Bahasa Indonesia.' : 'Write all content in English.'
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
}
export function configFor(rule: Rule, videoCredits = 0) {
const budget = budgetFor(rule, videoCredits)
const common = {
name: scheduleName(rule),
goal: goalText(rule),
instructions: instructionsFor(rule),
timezone: TIMEZONE,
run_budget: budget.run,
monthly_budget: budget.monthly,
}
if (rule.type === 'research') {
return {
...common,
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url'],
research_options: { max_results: 5 },
// Express is hands-off: accepted sources go straight into the Brain unless the user unticks this.
auto_approve_sources: rule.autoApprove,
run_schedule: researchSchedule(rule),
}
}
if (rule.kind === 'video') {
return {
...common,
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'studio.create_video'],
video_options: {
max_scenes: VIDEO.scenes,
max_duration_seconds: VIDEO.seconds,
art_style: 'realistic',
language: getLang() === 'id' ? 'Bahasa Indonesia' : 'English',
} satisfies VideoOptions,
run_schedule: { cadence: videoCadence(rule) ?? 'weekly', hour: 9, minute: 0, weekday: 0 } satisfies RunSchedule,
}
}
return {
...common,
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'compose.create_material'],
material_outputs: outputsFor(rule.kind),
content_goal: {
enabled: true,
weekly_target: weeklyTarget(rule),
execution_mode: 'automatic',
// "spread": the engine picks delivery days; a daily rule (target ≥ 7) produces every day.
production_cadence: 'spread',
production_hour: 9,
production_minute: 0,
weekly_budget: budget.weekly!,
} satisfies Partial<ContentGoal>,
}
}
const researchSchedule = (rule: ResearchRule): RunSchedule =>
({ cadence: rule.cadence, hour: 7, minute: 0, weekday: rule.weekday })
export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule {
if (rule.type === 'research') return true
if (rule.count < 1) return false
if (rule.kind === 'video') return videoCadence(rule) !== null
return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50
}
// The production corpus is the agent's own sources, so every ready Brain document is attached.
async function attachBrain(agentId: number) {
const docs = (await listDocuments()).filter(d => d.knowledge === 'ready')
for (const d of docs) {
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: d.docId } })
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
}
return docs.length
}
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
// A video schedule's run budget is today's price of one video, so it is fetched first.
const credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits) })
await attachBrain(agent.id)
// Activating approves the production rules as they stand; until then nothing runs.
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
}
/** Changing a rule changes what was approved, so the agent is re-activated right after. */
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
const rule = { ...schedule.rule, topic: topic.trim() }
const { name, goal, instructions } = configFor(rule)
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
await attachBrain(schedule.agent.id)
return request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
}
export const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
export async function resumeSchedule(id: number) {
await attachBrain(id)
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
}
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })
/** Starts one run of a research or video schedule now, outside its daily or weekly slot. */
export const runNow = (id: number) =>
request<{ id: number; status: string }>(`${AGENTS}/${id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`run-${id}`) }, json: {} })
// ── Status and history ──
export interface ProductionStatus {
enabled: boolean
goal_status: string | null
planning_available: boolean
period: { week_start: string; target: number; ready: number; claimed: number; failed: number; shortfall: number; spent_credits: number } | null
plans: ContentPlan[]
}
export interface ContentPlan {
id: number
revision: number
status: 'quoted' | 'planning' | 'review' | 'approved' | 'running' | 'completed' | 'paused' | 'failed' | 'cancelled'
target: number
error_code: string | null
planning_quote: { credits: number | null }
items: PlanItem[]
}
export interface PlanItem {
id: number
status: 'proposed' | 'approved' | 'queued' | 'generating' | 'ready' | 'blocked' | 'failed' | 'skipped' | 'cancelled' | 'rejected'
run_id: number | null
title: string
required_outputs: { kind: ContentKind }[]
}
const DROPPED = new Set<PlanItem['status']>(['skipped', 'cancelled', 'rejected'])
// One plan's progress, for the Schedule row and the Results banner.
export function planProgress(plan: ContentPlan) {
const items = plan.items.filter(i => !DROPPED.has(i.status))
return {
total: items.length,
ready: items.filter(i => i.status === 'ready').length,
failed: items.filter(i => i.status === 'failed' || i.status === 'blocked').length,
// The engine may find fewer new topics than asked for; the rest is a shortfall, not an error.
shortfall: plan.status === 'planning' ? 0 : Math.max(0, plan.target - items.length),
}
}
export const productionStatus = (id: number) => request<ProductionStatus>(`${AGENTS}/${id}/content`)
// Produce now = the same plan the 09:00 tick would create, just earlier. The engine's tick then
// approves and starts it within a minute because Express schedules run in automatic mode.
export const quoteProduction = (id: number, target: number) =>
request<ContentPlan>(`${AGENTS}/${id}/content/planning-quotes`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`produce-${id}`) }, json: { target } })
export const confirmProduction = (id: number, plan: ContentPlan) =>
request<ContentPlan>(`${AGENTS}/${id}/content/plans/${plan.id}/confirm-planning`, { method: 'POST', json: { revision: plan.revision } })
export const PLAN_ACTIVE = new Set<ContentPlan['status']>(['planning', 'review', 'approved', 'running'])
export interface RunSummary {
id: number
status: string
trigger_kind: string
safe_error_code: string | null
budget_spent: number | null
created_at: string
finished_at: string | null
}
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)

78
src/express/scorm.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { crc32, manifest, MASTERY, scoHtml, zip, type ScormLabels } from './scorm'
import type { Question } from './results'
const labels: ScormLabels = {
quiz: 'Quiz', submit: 'Check', retry: 'Retry', complete: 'Complete', completed: 'Done', score: 'Score: {score}%.',
passed: 'Passed', failed: 'Failed', answerAll: 'Answer all', noLms: 'No LMS',
}
const question = (q: Partial<Question>): Question =>
({ question: 'Q?', options: ['A', 'B'], answer: 'B', explanation: '', correct: 1, ...q })
// Reads a stored ZIP back through its central directory, as an LMS importer would.
function unzip(bytes: Uint8Array): Record<string, string> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const end = bytes.length - 22
expect(view.getUint32(end, true)).toBe(0x06054b50)
const count = view.getUint16(end + 10, true)
let at = view.getUint32(end + 16, true)
const files: Record<string, string> = {}
const dec = new TextDecoder()
for (let i = 0; i < count; i++) {
expect(view.getUint32(at, true)).toBe(0x02014b50)
const size = view.getUint32(at + 24, true)
const nameLength = view.getUint16(at + 28, true)
const offset = view.getUint32(at + 42, true)
const name = dec.decode(bytes.subarray(at + 46, at + 46 + nameLength))
expect(view.getUint32(offset, true)).toBe(0x04034b50)
const dataStart = offset + 30 + view.getUint16(offset + 26, true)
const data = bytes.subarray(dataStart, dataStart + size)
expect(crc32(data)).toBe(view.getUint32(at + 16, true))
files[name] = dec.decode(data)
at += 46 + nameLength
}
return files
}
describe('crc32', () => {
it('matches the standard check value', () => {
expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926)
})
})
describe('zip', () => {
it('round-trips files, including UTF-8 names and content', () => {
const files = unzip(zip([{ name: 'a.txt', data: 'hello' }, { name: 'é/b.html', data: '<p>Ringkasan ✓</p>' }]))
expect(files).toEqual({ 'a.txt': 'hello', 'é/b.html': '<p>Ringkasan ✓</p>' })
})
})
describe('manifest', () => {
it('declares one SCO and the mastery score only when there is a quiz', () => {
const withQuiz = manifest('Kepemimpinan & Tim', true, 'm1')
expect(withQuiz).toContain('adlcp:scormtype="sco" href="index.html"')
expect(withQuiz).toContain(`<adlcp:masteryscore>${MASTERY}</adlcp:masteryscore>`)
expect(withQuiz).toContain('<title>Kepemimpinan &amp; Tim</title>')
expect(manifest('Lesson', false)).not.toContain('masteryscore')
})
})
describe('scoHtml', () => {
it('keeps only scorable questions and cannot break out of its data script', () => {
const html = scoHtml({
title: 'T', lessonHtml: '<p>x</p>', lang: 'id', labels,
questions: [question({ question: '</script><b>' }), question({ options: [], correct: -1, answer: 'essay' })],
})
const data = JSON.parse(html.match(/<script type="application\/json" id="sco-data">(.*?)<\/script>/s)![1])
expect(data.quiz).toEqual([{ q: '</script><b>', o: ['A', 'B'], c: 1, e: '' }])
expect(data.mastery).toBe(MASTERY)
expect(html).toContain('id="quiz-form"')
})
it('offers a completion button when there is no quiz', () => {
const html = scoHtml({ title: 'T', lessonHtml: '', questions: [], lang: 'en', labels })
expect(html).toContain('id="complete"')
expect(html).not.toContain('id="quiz-form"')
})
})

265
src/express/scorm.ts Normal file
View File

@@ -0,0 +1,265 @@
// SCORM 1.2 export of a finished result: one self-contained SCO (index.html) that reports completion,
// and for content with a quiz also the score and pass/fail, to any SCORM 1.2 LMS.
// Built in the browser because the engine has no SCORM export; the ZIP writer below has no dependencies.
import type { Question } from './results'
export interface ScormLesson {
title: string
/** Static HTML of the lesson (already escaped by React's renderer); empty for a quiz-only package. */
lessonHtml: string
questions: Question[]
lang: 'en' | 'id'
labels: ScormLabels
}
export interface ScormLabels {
quiz: string
submit: string
retry: string
complete: string
completed: string
score: string
passed: string
failed: string
answerAll: string
noLms: string
}
/** Share of correct answers needed to pass, in percent (adlcp:masteryscore). */
export const MASTERY = 70
const escapeXml = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!)
export function manifest(title: string, hasQuiz: boolean, id = `lle-${Date.now().toString(36)}`): string {
const t = escapeXml(title)
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="${id}" version="1.0"
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
<metadata><schema>ADL SCORM</schema><schemaversion>1.2</schemaversion></metadata>
<organizations default="org">
<organization identifier="org">
<title>${t}</title>
<item identifier="item" identifierref="sco" isvisible="true">
<title>${t}</title>${hasQuiz ? `\n <adlcp:masteryscore>${MASTERY}</adlcp:masteryscore>` : ''}
</item>
</organization>
</organizations>
<resources>
<resource identifier="sco" type="webcontent" adlcp:scormtype="sco" href="index.html">
<file href="index.html"/>
</resource>
</resources>
</manifest>
`
}
// Inside <script>, "</" would end the element early; < keeps the JSON intact.
const jsonForScript = (v: unknown) => JSON.stringify(v).replace(/</g, '\\u003c')
export function scoHtml({ title, lessonHtml, questions, lang, labels }: ScormLesson): string {
// Only multiple-choice questions can be scored; essay questions stay out of the package's quiz.
const quiz = questions.filter(q => q.options.length > 1 && q.correct >= 0)
.map(q => ({ q: q.question, o: q.options, c: q.correct, e: q.explanation }))
return `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeXml(title)}</title>
<style>${SCO_CSS}</style>
</head>
<body>
<main>
<h1>${escapeXml(title)}</h1>
${lessonHtml}
${quiz.length ? `<section id="quiz"><h2>${escapeXml(labels.quiz)}</h2><form id="quiz-form"></form><p id="quiz-result" role="status"></p></section>` : `<p class="end"><button id="complete" type="button">${escapeXml(labels.complete)}</button></p>`}
<p id="lms-note" class="note" hidden>${escapeXml(labels.noLms)}</p>
</main>
<script type="application/json" id="sco-data">${jsonForScript({ quiz, labels, mastery: MASTERY })}</script>
<script>${SCO_JS}</script>
</body>
</html>
`
}
const SCO_CSS = `
*{box-sizing:border-box}
body{margin:0;background:#f5f8fc;color:#1d2a3b;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
main{max-width:760px;margin:0 auto;padding:32px 20px 60px;background:#fff;min-height:100vh}
h1{font-size:28px;line-height:1.3;margin:0 0 20px}
article>h1{display:none}
h2{font-size:20px;margin:28px 0 10px}
ul,ol{padding-left:22px}
.muted,.note{color:#6b7a90;font-size:14px}
.flashcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
.flashcard{border:1px solid #d9e2ef;border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:6px}
.slide-preview{border:1px solid #d9e2ef;border-radius:8px;padding:12px 16px;margin-bottom:10px}
.slide-preview small{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#8a97a8}
fieldset{border:1px solid #d9e2ef;border-radius:10px;padding:14px 16px;margin:0 0 14px}
legend{font-weight:600;padding:0 6px}
label{display:flex;gap:10px;align-items:flex-start;padding:6px 4px;border-radius:6px;cursor:pointer}
label.correct{background:#e8f7f0}
label.wrong{background:#fdecec}
.explain{font-size:14px;color:#4d5d73;margin:6px 0 0}
button{font:inherit;border:0;border-radius:8px;padding:10px 18px;background:#2563eb;color:#fff;cursor:pointer}
button:disabled{opacity:.5;cursor:default}
#quiz-result{font-weight:600}
.end{margin-top:32px}
`
// SCORM 1.2 runtime: find the LMS's API object, mark the lesson incomplete on open, and report
// completion (no quiz) or score + passed/failed (quiz). Works without an LMS too, for a preview.
const SCO_JS = `
(function(){
var data=JSON.parse(document.getElementById('sco-data').textContent),L=data.labels,start=Date.now(),api=null,done=false;
function find(w){for(var n=0;w&&n<12;n++){if(w.API)return w.API;if(!w.parent||w.parent===w)break;w=w.parent}return null}
api=find(window)||(window.opener?find(window.opener):null);
function set(k,v){if(api)api.LMSSetValue(k,String(v))}
function commit(){if(api)api.LMSCommit('')}
function pad(n,w){n=String(n);while(n.length<w)n='0'+n;return n}
function elapsed(){var s=Math.round((Date.now()-start)/1000);return pad(Math.floor(s/3600),4)+':'+pad(Math.floor(s/60)%60,2)+':'+pad(s%60,2)}
if(api){
api.LMSInitialize('');
var status=api.LMSGetValue('cmi.core.lesson_status');
if(!status||status==='not attempted')set('cmi.core.lesson_status','incomplete');
commit();
}else document.getElementById('lms-note').hidden=false;
function finish(){if(!api||done)return;done=true;set('cmi.core.session_time',elapsed());set('cmi.core.exit','');commit();api.LMSFinish('')}
window.addEventListener('pagehide',finish);
window.addEventListener('beforeunload',finish);
var complete=document.getElementById('complete');
if(complete)complete.onclick=function(){set('cmi.core.lesson_status','completed');commit();complete.disabled=true;complete.textContent=L.completed};
var form=document.getElementById('quiz-form');
if(!form)return;
data.quiz.forEach(function(q,i){
var f=document.createElement('fieldset'),lg=document.createElement('legend');
lg.textContent=(i+1)+'. '+q.q;f.appendChild(lg);
q.o.forEach(function(o,j){
var l=document.createElement('label'),r=document.createElement('input'),s=document.createElement('span');
r.type='radio';r.name='q'+i;r.value=j;s.textContent=o;l.appendChild(r);l.appendChild(s);f.appendChild(l);
});
form.appendChild(f);
});
var submit=document.createElement('button');submit.type='submit';submit.textContent=L.submit;form.appendChild(submit);
var result=document.getElementById('quiz-result');
form.onsubmit=function(e){
e.preventDefault();
if(submit.dataset.retry){form.reset();form.querySelectorAll('label').forEach(function(l){l.className=''});
form.querySelectorAll('.explain').forEach(function(x){x.remove()});form.querySelectorAll('input').forEach(function(x){x.disabled=false});
delete submit.dataset.retry;submit.textContent=L.submit;result.textContent='';return}
var answers=data.quiz.map(function(q,i){var c=form.querySelector('input[name=q'+i+']:checked');return c?Number(c.value):-1});
if(answers.indexOf(-1)>=0){result.textContent=L.answerAll;return}
var right=0;
data.quiz.forEach(function(q,i){
var set_=form.querySelectorAll('fieldset')[i],labels=set_.querySelectorAll('label');
labels[q.c].className='correct';if(answers[i]!==q.c)labels[answers[i]].className='wrong';else right++;
if(q.e){var p=document.createElement('p');p.className='explain';p.textContent=q.e;set_.appendChild(p)}
set_.querySelectorAll('input').forEach(function(x){x.disabled=true});
});
var score=Math.round(right/data.quiz.length*100),passed=score>=data.mastery;
set('cmi.core.score.min',0);set('cmi.core.score.max',100);set('cmi.core.score.raw',score);
set('cmi.core.lesson_status',passed?'passed':'failed');commit();
result.textContent=L.score.replace('{score}',score)+' '+(passed?L.passed:L.failed);
submit.dataset.retry='1';submit.textContent=L.retry;
};
})();
`
// ── ZIP (stored, no compression: the package is small text) ──
const CRC_TABLE = (() => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
table[n] = c >>> 0
}
return table
})()
export function crc32(bytes: Uint8Array): number {
let c = 0xffffffff
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
function dosDateTime(d: Date) {
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
date: ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
}
}
export function zip(files: { name: string; data: string | Uint8Array }[], now = new Date()): Uint8Array {
const enc = new TextEncoder()
const { time, date } = dosDateTime(now)
const locals: Uint8Array[] = []
const centrals: Uint8Array[] = []
let offset = 0
for (const f of files) {
const name = enc.encode(f.name)
const data = typeof f.data === 'string' ? enc.encode(f.data) : f.data
const crc = crc32(data)
const local = new Uint8Array(30 + name.length + data.length)
const lv = new DataView(local.buffer)
lv.setUint32(0, 0x04034b50, true)
lv.setUint16(4, 20, true) // version needed
lv.setUint16(6, 0x0800, true) // UTF-8 names
lv.setUint16(8, 0, true) // stored
lv.setUint16(10, time, true)
lv.setUint16(12, date, true)
lv.setUint32(14, crc, true)
lv.setUint32(18, data.length, true)
lv.setUint32(22, data.length, true)
lv.setUint16(26, name.length, true)
local.set(name, 30)
local.set(data, 30 + name.length)
const central = new Uint8Array(46 + name.length)
const cv = new DataView(central.buffer)
cv.setUint32(0, 0x02014b50, true)
cv.setUint16(4, 20, true)
cv.setUint16(6, 20, true)
cv.setUint16(8, 0x0800, true)
cv.setUint16(10, 0, true)
cv.setUint16(12, time, true)
cv.setUint16(14, date, true)
cv.setUint32(16, crc, true)
cv.setUint32(20, data.length, true)
cv.setUint32(24, data.length, true)
cv.setUint16(28, name.length, true)
cv.setUint32(42, offset, true)
central.set(name, 46)
locals.push(local)
centrals.push(central)
offset += local.length
}
const centralSize = centrals.reduce((n, c) => n + c.length, 0)
const end = new Uint8Array(22)
const ev = new DataView(end.buffer)
ev.setUint32(0, 0x06054b50, true)
ev.setUint16(8, files.length, true)
ev.setUint16(10, files.length, true)
ev.setUint32(12, centralSize, true)
ev.setUint32(16, offset, true)
const out = new Uint8Array(offset + centralSize + end.length)
let at = 0
for (const part of [...locals, ...centrals, end]) { out.set(part, at); at += part.length }
return out
}
export function scormPackage(lesson: ScormLesson): Blob {
const hasQuiz = lesson.questions.some(q => q.options.length > 1 && q.correct >= 0)
const bytes = zip([
{ name: 'imsmanifest.xml', data: manifest(lesson.title, hasQuiz) },
{ name: 'index.html', data: scoHtml(lesson) },
])
return new Blob([bytes as BlobPart], { type: 'application/zip' })
}