The agent answers in the language each message is written in (Indonesian or English), whatever the UI language; short replies such as "LLM" keep the conversation's language. inLang() runs t() in that language. Each turn now reads like a person: a thinking step with what the agent is doing, the reply typed out with "Thought for Ns" above it, a short typing pause, then one question, with its options shown once it is typed. Reduced motion skips the animation. "dari file LLM" / "from the document X" is read as the topic, and topics keep the user's capitals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
10 KiB
TypeScript
219 lines
10 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)\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'
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
|
|
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
|
|
}
|
|
|
|
// "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)(.+)$/
|
|
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
|
|
}
|
|
|
|
export 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'
|
|
}
|
|
|
|
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))`
|
|
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'
|
|
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) 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 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)
|
|
// 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
|