Hanabank's users find this name easier to understand. Display text changes in English and Indonesian, and "1.2" goes with it. The package format, internal ids and schedule names stay as they are. The chat's example now says INTERACTIVE, so the parser reads that word as well. A bare "interactive" only counts when no other kind is named, so "kuis interaktif" stays a quiz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
16 KiB
TypeScript
306 lines
16 KiB
TypeScript
// 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|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<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'
|
|
|
|
// 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`(?<!\p{L})(?:(?:in|dalam|pakai|pake|memakai|menggunakan|gunakan|using|use|with|berbahasa)\s+)?(?:the\s+)?(?:${LANGUAGE_CORE})(?:\s+language)?(?!\p{L})`,
|
|
'gu',
|
|
)
|
|
|
|
/** The output language a text names, if any. */
|
|
export function languageOf(text: string): OutputLanguage | null {
|
|
const m = text.toLowerCase().match(LANGUAGE_PHRASE)?.[0]
|
|
if (!m) return null
|
|
return /indo/.test(m) ? 'Bahasa Indonesia' : /english|inggris/.test(m) ? 'English' : '中文'
|
|
}
|
|
|
|
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
|
|
/** The language the content is written in; unset means the UI language. */
|
|
language?: OutputLanguage
|
|
/** Made from material chosen in the Brain panel rather than the whole Brain. */
|
|
chosen?: boolean
|
|
/** The instruction said "content" without a kind, so the kind is only a placeholder the agent asks about. */
|
|
kindGuessed?: boolean
|
|
/** The instruction named no frequency, so per week is only a placeholder the agent asks about. */
|
|
perGuessed?: boolean
|
|
}
|
|
|
|
export interface ResearchRule {
|
|
type: 'research'
|
|
cadence: 'daily' | 'weekly'
|
|
weekday: number
|
|
topic: string
|
|
autoApprove: boolean
|
|
/** Weekly research whose instruction named no day, so Monday is only a default the agent asks about. */
|
|
weekdayGuessed?: boolean
|
|
}
|
|
|
|
export type Rule = ContentRule | ResearchRule
|
|
|
|
/** Something the instruction asked for that is not a rule: the agent answers it truthfully instead of ignoring it. */
|
|
export interface Note { key: Key; vars?: Record<string, string | number> }
|
|
|
|
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
|