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

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