// 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 = 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|interactive modules?|modul(e|es)? interaktif)\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/], ] // The interface calls SCORM "INTERACTIVE", so the bare word means it too, but only when nothing else // is named: "kuis interaktif" stays a quiz, and "summaries about interactive learning" stay summaries. const INTERACTIVE = /\b(interactive|interaktif)\b/ /** Every content kind a text names, in the order it names them: "ringkasan dan kuis" → summary, quiz. */ export function kindsIn(text: string): ContentKind[] { const kinds = KIND_WORDS .map(([kind, re]) => [kind, text.search(re)] as const) .filter(([, at]) => at >= 0) .sort((a, b) => a[1] - b[1]) .map(([kind]) => kind) return kinds.length || !INTERACTIVE.test(text) ? kinds : ['scorm'] } // "10 konten", "5 materi pembelajaran", "10 pdf": content of no particular kind, so the agent asks which. // Only with a count or a create verb before it, so "gunakan seluruh materi" is not a request. const GENERIC = /\b(konten|content|contents|materi|materials?|bahan ajar|pdf|pdfs|dokumen|documents?|modul|modules?|items?)\b/ const CREATE = /\b(bikin|bikinin|buat|buatkan|membuat|hasilkan|produksi|make|create|generate|produce|write|tulis)\b/ const NUMBER_WORDS: Record = { 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' // The output languages the engine writes in (generation_brief.OutputLanguage, AgentVideoOptions.language). export type OutputLanguage = 'English' | 'Bahasa Indonesia' | '中文' export const LANGUAGES: OutputLanguage[] = ['Bahasa Indonesia', 'English', '中文'] export const languageLabel = (l: OutputLanguage) => (l === '中文' ? '中文 (Mandarin)' : l) // "in English", "pakai bahasa Inggris", "dalam bahasa Mandarin". A bare country name needs "bahasa", // so a topic such as "sales in China" is not read as a language. const LANGUAGE_CORE = String.raw`bahasa\s+(?:indonesia|indo|inggris|english|mandarin|cina|china|tionghoa)|indonesian|english|inggris|mandarin|chinese|中文` const LANGUAGE_PHRASE = new RegExp( String.raw`(? } export interface ParseResult { rules: Rule[] /** Clauses that said something but matched no rule, so the user can rephrase them. */ unknown: string[] /** The output language the instruction named. */ language?: OutputLanguage notes: Note[] } // Requests about how schedules work rather than what they make. Each is answered with what Express // actually does, so the agent neither ignores them nor promises something it cannot do. const META: [RegExp, Key | null][] = [ [/\b(?:seluruh|semua)\s+(?:materi|bahan|dokumen|otak|brain|isi)|\bwhole brain\b|\ball (?:the )?(?:materials?|documents?|brain)\b/, 'note.wholeBrain'], [/duplika|duplicat|\b(?:tidak|jangan|tanpa|bukan)\s+(?:meng)?ulang|\b(?:without|not|don'?t|no)\s+repeat|\b(?:topik|topics?)\s+(?:yang\s+)?(?:berbeda|beda|different|unik|unique)|\bdifferent topics?\b/, 'note.novelty'], [/\b(?:draf|draft|drafts|ditinjau|tinjau|peninjauan|reviews?|reviewed|approval|persetujuan|disetujui|dipublikasikan|publikasi|published?)\b/, 'note.draft'], [/\b(?:scheduler|penjadwal|background|latar belakang|cron|otomatis|automatic(?:ally)?|terjadwal)\b/, 'note.automatic'], // Wishes every schedule already aims for; nothing to add, but not a part the agent failed to understand. [/\b(?:relevan|relevant|informatif|informative|berkualitas|bermanfaat|menarik|useful|engaging|quality|referensi|reference)\b/, null], ] const HOUR = /\b(?:pukul|pkl|jam|at)\s*(\d{1,2})(?:[.:](\d{2}))?\s*(am|pm)?/ const SUB_DAY = /\b\d+\s*(?:menit|jam|detik|minutes?|mins?|hours?|seconds?)\b|\b(?:tiap|setiap|per|every|each)\s+(?:menit|jam|minute|hour)\b|\bhourly\b/ function notesOf(text: string): Note[] { const notes: Note[] = META.flatMap(([re, key]) => (key && re.test(text) ? [{ key }] : [])) const hour = text.match(HOUR) if (hour) { let h = Number(hour[1]) if (hour[3] === 'pm' && h < 12) h += 12 if (hour[3] === 'am' && h === 12) h = 0 const at = `${String(h).padStart(2, '0')}.${hour[2] ?? '00'}` notes.push({ key: at === '09.00' ? 'note.hourMatches' : 'note.hourFixed', vars: { at } }) } if (SUB_DAY.test(text)) notes.push({ key: 'note.subDay' }) return notes } const isMeta = (clause: string) => META.some(([re]) => re.test(clause)) || HOUR.test(clause) || SUB_DAY.test(clause) // "dari file LLM" / "from the document onboarding" names a Brain file, which works as the topic. const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic|(?:dari|from)(?: the)? (?:file|berkas|dokumen|documents?|materi|material))\s+(?!latihan\b|yang\b)(.+)$/ const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month|pada pukul|pada jam|pukul|pkl|secara|otomatis|automatically|dalam \d|in \d|at \d)\b.*$/ function topicOf(clause: string): string { return clause.match(TOPIC)?.[1].replace(FREQUENCY_TAIL, '').replace(/[\s,]+$/, '').trim() ?? '' } /** The count right before a kind word, or null when the instruction named none. */ function countBefore(clause: string, kindAt: number): number | null { 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]) || null } const countOf = (clause: string, kindAt: number) => countBefore(clause, kindAt) ?? 1 const DAY_WORDS = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/ const WEEK_WORDS = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/ const MONTH_WORDS = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/ export function perOf(clause: string): Per | 'month' { if (DAY_WORDS.test(clause)) return 'day' if (MONTH_WORDS.test(clause)) return 'month' return 'week' } /** Whether a clause says how often, rather than leaving perOf to fall back to per week. */ export const namesFrequency = (clause: string) => DAY_WORDS.test(clause) || WEEK_WORDS.test(clause) || MONTH_WORDS.test(clause) || weekdayOf(clause) !== null export 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))` // A full stop ends a sentence, but not inside a time or number such as "09.00". const SPLIT = new RegExp(String.raw`(?:[\n;]|\.(?!\d))+|\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' const rule: ResearchRule = { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true } if (cadence === 'weekly' && day === null) rule.weekdayGuessed = true return rule } for (const [kind, re] of KIND_WORDS) { const m = re.exec(clause) if (m) return contentRule(clause, kind, countOf(clause, m.index)) } const interactive = INTERACTIVE.exec(clause) if (interactive) return contentRule(clause, 'scorm', countOf(clause, interactive.index)) const g = GENERIC.exec(clause) if (g) { const count = countBefore(clause, g.index) if (count !== null || CREATE.test(clause.slice(0, g.index))) return { ...contentRule(clause, 'summary', count ?? 1), kindGuessed: true } } return null } function contentRule(clause: string, kind: ContentKind, count: number): ContentRule { const per = perOf(clause) const guessed = namesFrequency(clause) ? {} : { perGuessed: true } 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), ...guessed } } export function parseInstruction(text: string): ParseResult { const language = languageOf(text) const clauses = text .toLowerCase() .replace(LANGUAGE_PHRASE, ' ') .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) // "simpan sebagai draft", "jalankan otomatis": answered as notes, not asked about. else if (isMeta(clause)) continue // 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 // Topics are found in lower case; they keep the user's own capitals ("LLM", "HR"). const lowered = text.toLowerCase() if (lowered.length === text.length) { for (const r of rules) { const at = r.topic ? lowered.indexOf(r.topic) : -1 if (at >= 0) r.topic = text.slice(at, at + r.topic.length) } } const notes = notesOf(text.toLowerCase()) return language ? { rules, unknown, language, notes } : { rules, unknown, notes } } 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) /** `from` names the chosen material the rule is made from; without it, content comes from the whole Brain. */ export function describeRule(rule: Rule, from?: string): 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 }) } // A placeholder kind or frequency is shown as still open, not as if the user had chosen it. return t('rule.content', { kind: rule.kindGuessed ? t('rule.anyKind') : kindLabel(rule.kind), count: rule.count, per: rule.perGuessed ? t('rule.perUnset') : t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'), about: about || (from ?? (rule.chosen ? t('rule.chosenMaterial') : null) ? t('rule.from', { source: from ?? t('rule.chosenMaterial') }) : t('rule.wholeBrain')), }) } export const monthNote = (rule: ContentRule) => rule.monthly ? t('rule.monthNote', { count: rule.monthly, weekly: rule.count }) : null