// 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)\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 = { 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`(? 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 // 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) } } return language ? { rules, unknown, language } : { 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