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:
80
src/express/parse.test.ts
Normal file
80
src/express/parse.test.ts
Normal 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
179
src/express/parse.ts
Normal 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
|
||||
54
src/express/results.test.ts
Normal file
54
src/express/results.test.ts
Normal 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
331
src/express/results.ts
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
|
||||
`
|
||||
88
src/express/schedules.test.ts
Normal file
88
src/express/schedules.test.ts
Normal 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
316
src/express/schedules.ts
Normal 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
78
src/express/scorm.test.ts
Normal 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 & 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
265
src/express/scorm.ts
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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' })
|
||||
}
|
||||
Reference in New Issue
Block a user