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