Make the Brain agent ask instead of guess, and answer how schedules run

Generic requests ("10 konten", "10 pdf") now become a rule whose kind the
agent asks about, and a rule with no frequency is asked about instead of
silently becoming weekly. Times like 09.00 no longer split sentences, and
requests about drafts, the hour, scheduling, sources and duplicates get a
truthful note rather than an "I did not understand" loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-28 14:09:27 +07:00
parent d018c6826f
commit b63e9c2ca8
5 changed files with 293 additions and 33 deletions

View File

@@ -87,6 +87,53 @@ describe('instruction agent', () => {
})
})
describe('instruction agent with vague or long instructions', () => {
const LONG = `Buat 5 konten pembelajaran baru bertema Management Skills setiap hari secara otomatis pada pukul 09.00 WIB.
Gunakan seluruh materi pembelajaran yang tersedia di dalam sistem sebagai referensi. Pastikan setiap konten memiliki topik yang berbeda, relevan, informatif, dan tidak menduplikasi konten yang sudah pernah dibuat.
Simpan seluruh konten yang berhasil dibuat ke dalam sistem dengan status Draft untuk ditinjau oleh admin sebelum dipublikasikan.
Jalankan proses ini menggunakan scheduler atau background job yang tersedia di sistem.`
it('reads a long brief as one rule and answers the rest truthfully instead of asking about it', () => {
const turn = respond(EMPTY, LONG)
expect(rules(turn.state)).toEqual([expect.objectContaining({ count: 5, per: 'day', topic: 'Management Skills', kindGuessed: true })])
expect(turn.state.unknown).toEqual([])
for (const key of ['note.draft', 'note.novelty', 'note.automatic', 'note.wholeBrain'] as const) expect(turn.reply).toContain(t(key))
expect(turn.reply).toContain(t('note.hourMatches', { at: '09.00' }))
expect(nextQuestion(turn.state)?.type).toBe('kind')
})
it('asks which kind "content" means, and splits the count over the kinds named', () => {
const s = say(LONG, 'ringkasan dan kuis')
expect(rules(s)).toMatchObject([{ kind: 'summary', count: 3 }, { kind: 'quiz', count: 2 }])
expect(rules(say(LONG, t('agent.optMix'))).map(r => r.type === 'content' && r.kind)).toEqual(['summary', 'quiz', 'flashcards'])
})
it('asks how often instead of assuming per week', () => {
const s = say('bikin 10 konten baru dalam 20 menit', 'kuis')
expect(nextQuestion(s)?.type).toBe('per')
expect(rules(say('bikin 10 konten baru dalam 20 menit', 'kuis', t('agent.optEveryWeek')))[0]).toMatchObject({ kind: 'quiz', count: 10, per: 'week', perGuessed: undefined })
})
it('says plainly that it cannot work in minutes', () => {
expect(respond(EMPTY, 'bikin 10 konten baru dalam 20 menit').reply).toContain(t('note.subDay'))
})
it('replaces the placeholder when the user restates the request, rather than adding another', () => {
const s = say('10 pdf per day', '10 konten')
expect(rules(s)).toHaveLength(1)
expect(rules(s)[0]).toMatchObject({ count: 10, per: 'day', kindGuessed: true })
})
it('does not repeat the same question when a rephrasing still makes no sense', () => {
const s = say('2 summaries per day about sales. hello there', 'blabla')
expect(s.unknown).toEqual([])
expect(nextQuestion(s)?.type).toBe('language')
})
})
describe('chatLangOf', () => {
it('tells Indonesian from English, and stays undecided on short replies', () => {
expect(chatLangOf('Saya mau buat video dari file LLM')).toBe('id')

View File

@@ -4,8 +4,8 @@
// user to spot alone.
import { t, type Key, type Lang } from '../i18n'
import {
dayName, describeRule, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf, weeklyTarget,
type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
dayName, describeRule, KINDS, kindLabel, kindsIn, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf,
weeklyTarget, type ContentKind, type ContentRule, type Note, type OutputLanguage, type ResearchRule, type Rule,
} from './parse'
export interface Item { id: number; rule: Rule }
@@ -26,13 +26,20 @@ export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language
export type Question =
| { key: string; type: 'unknown'; text: string }
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
| { key: string; type: 'kind' | 'video' | 'tooMany' | 'weekday'; id: number }
| { key: string; type: 'topic'; ids: number[] }
| { key: string; type: 'per'; ids: number[] }
| { key: string; type: 'language' }
/** What the plan still needs, in the order the agent asks about it. */
export function nextQuestion(s: AgentState): Question | null {
if (s.unknown.length) return { key: `unknown:${s.unknown[0]}`, type: 'unknown', text: s.unknown[0] }
// The kind and the frequency come first: the other checks depend on them.
const generic = s.items.find(i => i.rule.type === 'content' && i.rule.kindGuessed)
if (generic) return { key: `kind:${generic.id}`, type: 'kind', id: generic.id }
// One question for every rule with no frequency, since they usually come from the same request.
const unset = s.items.filter(i => i.rule.type === 'content' && i.rule.perGuessed).map(i => i.id)
if (unset.length) return { key: `per:${unset.join(',')}`, type: 'per', ids: unset }
for (const { id, rule } of s.items) {
if (rule.type === 'content' && rule.kind === 'video' && videoCadence(rule) === null) return { key: `video:${id}`, type: 'video', id }
if (rule.type === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id }
@@ -51,12 +58,27 @@ const describe = (s: AgentState, rule: Rule) => describeRule(rule, s.sources ??
const bullets = (s: AgentState, rules: Rule[]) => rules.map(r => `• ${describe(s, r)}`).join('\n')
const about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '')
const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule
const contentById = (s: AgentState, id: number) => ruleById(s, id) as ContentRule
// "Mix" splits a generic request over the three kinds most teams start with.
const MIX: ContentKind[] = ['summary', 'quiz', 'flashcards']
/** The agent's question, with replies the user can pick instead of typing. */
export function ask(q: Question, s: AgentState): { text: string; options: string[] } {
switch (q.type) {
case 'unknown':
return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] }
case 'kind': {
const rule = contentById(s, q.id)
return {
text: t('agent.askKind', { count: rule.count, about: about(rule) }),
options: [...KINDS.map(kindLabel), t('agent.optMix')],
}
}
case 'per':
return {
text: t('agent.askPer', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
options: [t('agent.optEveryDay'), t('agent.optEveryWeek')],
}
case 'video':
return { text: t('agent.askVideo', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
case 'tooMany':
@@ -79,6 +101,8 @@ export function editRule(s: AgentState, id: number, patch: Partial<ContentRule>
// A count or frequency typed in by hand replaces the monthly request it was derived from.
...('count' in patch || 'per' in patch ? { monthly: undefined } : {}),
...('weekday' in patch || 'cadence' in patch ? { weekdayGuessed: undefined } : {}),
...('kind' in patch ? { kindGuessed: undefined } : {}),
...('per' in patch ? { perGuessed: undefined } : {}),
}
return { ...s, items: s.items.map(i => (i.id === id ? { ...i, rule: { ...i.rule, ...patch, ...guessed } as Rule } : i)) }
}
@@ -88,10 +112,11 @@ export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, it
// Words that tell which language a message is written in, so the agent can answer in it.
const ID_CHAT = new Set(('saya aku mau ingin bikin bikinin buat buatkan tolong dong ya iya tidak gak nggak enggak yang dan dengan untuk dari '
+ 'ke di ini itu apa bagaimana gimana hari minggu bulan setiap tiap soal tentang mengenai riset ringkasan kuis lewati hapus seluruh '
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai').split(' '))
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai konten baru materi '
+ 'pembelajaran menit jam dalam simpan gunakan pastikan').split(' '))
const EN_CHAT = new Set(('i me my want would like make create please the and with for from to in this that what how day week month every '
+ 'each about research summary summaries quiz quizzes skip remove whole brain monday tuesday wednesday thursday friday saturday '
+ 'sunday yes no okay sure start use language also just now').split(' '))
+ 'sunday yes no okay sure start use language also just now content new minutes hours save make sure').split(' '))
/** The language a message is written in, or null when it is too short or mixed to tell. */
export function chatLangOf(text: string): Lang | null {
@@ -109,19 +134,72 @@ const REMOVE = /^(remove|hapus|buang|delete)\b/
const WHOLE = /^(whole brain|the whole brain|seluruh otak|semua|semuanya|all|everything|apa saja|bebas)$/
const DAILY = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/
const WEEKLY = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/
const MONTHLY = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/
const MIX_REPLY = /^(campur|campuran|mix|mixed|bebas|terserah|apa saja|any)\b/
let seq = 0
const withIds = (rules: Rule[]) => rules.map(rule => ({ id: ++seq, rule }))
// A new instruction: its rules join the plan and its unclear parts are queued as questions.
const noteText = (notes: Note[]) => notes.map(n => `\n${t(n.key, n.vars)}`).join('')
// A new instruction: its rules join the plan and its unclear parts are queued as questions. Requests about
// how schedules run (drafts, the hour, "automatically") get a truthful note instead of being ignored.
function instruct(s: AgentState, text: string): Turn | null {
const { rules, unknown, language } = parseInstruction(text)
const languageNote = language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : ''
if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
return {
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language },
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + languageNote,
const { rules, unknown, language, notes } = parseInstruction(text)
const extra = (language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : '') + noteText(notes)
if (!rules.length) return extra ? { state: { ...s, language: language ?? s.language }, reply: extra.trim() } : null
let state: AgentState = { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language }
// "Gunakan seluruh materi" already answers the topic question with the whole Brain.
const q = nextQuestion(state)
if (q?.type === 'topic' && notes.some(n => n.key === 'note.wholeBrain')) state = { ...state, settled: [...state.settled, q.key] }
return { state, reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + extra }
}
// A placeholder rule becomes the rules the reply asks for, in its place in the plan.
function replaceRule(s: AgentState, id: number, rules: Rule[], notes: Note[] = []): Turn {
const at = s.items.findIndex(i => i.id === id)
const items = [...s.items.slice(0, at), ...withIds(rules), ...s.items.slice(at + 1)]
return { state: { ...s, items }, reply: t('agent.kindSet', { list: bullets(s, rules) }) + noteText(notes) }
}
// "Ringkasan dan kuis" for "10 konten" splits the count: 5 summaries and 5 quizzes.
function answerKind(s: AgentState, id: number, text: string, lower: string, mix: boolean): Turn | null {
const rule = contentById(s, id)
// A full instruction ("2 ringkasan per hari", or "10 konten" again) restates the request, so it
// replaces the placeholder instead of adding a second one; what it leaves out is kept from before.
if (/\d/.test(lower)) {
const { rules: parsed, notes } = parseInstruction(text)
if (!parsed.length) return null
return replaceRule(s, id, parsed.map(r => {
if (r.type === 'research') return r.topic ? r : { ...r, topic: rule.topic }
const keepPer = r.perGuessed && !rule.perGuessed ? { per: rule.per, perGuessed: undefined } : {}
return { ...r, topic: r.topic || rule.topic, ...keepPer }
}), notes)
}
const kinds = mix ? MIX : [...new Set([...kindsIn(lower), ...KINDS.filter(k => lower === kindLabel(k).toLowerCase())])]
if (!kinds.length) return null
const { kindGuessed: _, ...base } = rule
return replaceRule(s, id, kinds.map((kind, i) => ({
...base,
kind,
count: Math.max(1, Math.floor(rule.count / kinds.length) + (i < rule.count % kinds.length ? 1 : 0)),
})))
}
// "Per hari" sets the frequency of every rule asked about; a number ("5 per hari") only when there is one.
function answerPer(s: AgentState, ids: number[], lower: string): Turn | null {
// A reply naming a kind or research is a new request, not a frequency for these.
if (kindsIn(lower).length || /\b(riset|research)\b/.test(lower)) return null
const n = ids.length === 1 ? Number(lower.match(/\d+/)?.[0]) || null : null
const monthly = MONTHLY.test(lower) || /\b(bulan|month)\b/.test(lower)
const per = monthly || WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week'
: DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : null
if (!per) return null
const state = ids.reduce((st, id) => {
const count = n ?? contentById(st, id).count
return editRule(st, id, { per, count: monthly ? Math.max(1, Math.round(count / 4)) : count })
}, s)
return { state, reply: t('agent.kindSet', { list: bullets(state, ids.map(id => ruleById(state, id))) }) }
}
// A reply to the language question may be short ("indo", "inggris", "zh"), so it is read more loosely.
@@ -157,9 +235,11 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) }
}
if (q.type === 'per') return answerPer(s, q.ids, is('agent.optEveryDay') ? 'per hari' : is('agent.optEveryWeek') ? 'per minggu' : lower)
if (REMOVE.test(lower) || is('agent.optRemove')) {
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describe(s, ruleById(s, q.id)) }) }
}
if (q.type === 'kind') return answerKind(s, q.id, text, lower, MIX_REPLY.test(lower) || is('agent.optMix'))
const per = DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week' : null
if (q.type === 'video') return per ? updated(s, q.id, { count: 1, per }) : null
if (q.type === 'tooMany') {
@@ -182,8 +262,13 @@ export function respond(s: AgentState, input: string): Turn {
const q = nextQuestion(s)
if (q) {
// Not an answer, so maybe a new instruction; otherwise the same question is asked again.
return answer(q, s, text, lower, is) ?? instruct(s, text) ?? { state: s, reply: t('agent.unclear') }
const turn = answer(q, s, text, lower, is) ?? instruct(s, text)
if (turn) return turn
// A rephrasing that still made no sense: asking about the old wording again would loop, so it is
// dropped and the agent says what it can do instead.
if (q.type === 'unknown') return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.notUnderstood') }
// Otherwise the same question is asked again.
return { state: s, reply: t('agent.unclear') }
}
if (s.items.length && (YES.test(lower) || is('agent.optApprove'))) return { state: s, reply: '', approve: true }
return instruct(s, text) ?? { state: s, reply: t('agent.notUnderstood') }

View File

@@ -78,3 +78,26 @@ describe('counts', () => {
expect(parseInstruction('buat 0 ringkasan per minggu').rules[0]).toMatchObject({ kind: 'summary', count: 1 })
})
})
describe('generic content and sentences', () => {
it('reads "konten" and "pdf" with a count as content of a kind still to choose', () => {
expect(content('10 pdf per day')[0]).toMatchObject({ kind: 'summary', count: 10, per: 'day', kindGuessed: true })
expect(content('bikin konten tiap minggu soal sales')[0]).toMatchObject({ kindGuessed: true, topic: 'sales' })
})
it('does not read "gunakan seluruh materi" as a request', () => {
expect(parseInstruction('gunakan seluruh materi pembelajaran sebagai referensi').rules).toEqual([])
})
it('marks a rule with no frequency instead of silently making it weekly', () => {
expect(content('3 kuis soal sales')[0]).toMatchObject({ per: 'week', perGuessed: true })
expect(content('3 kuis per minggu soal sales')[0].perGuessed).toBeUndefined()
})
it('does not end a sentence inside a time such as 09.00', () => {
const { rules, unknown } = parseInstruction('buat 2 kuis tiap hari soal sales pada pukul 09.00 WIB. simpan sebagai draft')
expect(rules).toHaveLength(1)
expect(rules[0]).toMatchObject({ topic: 'sales' })
expect(unknown).toEqual([])
})
})

View File

@@ -30,6 +30,20 @@ const KIND_WORDS: [ContentKind, RegExp][] = [
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
]
/** Every content kind a text names, in the order it names them: "ringkasan dan kuis" → summary, quiz. */
export function kindsIn(text: string): ContentKind[] {
return 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)
}
// "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,
@@ -77,6 +91,10 @@ export interface ContentRule {
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 {
@@ -91,37 +109,81 @@ export interface ResearchRule {
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)(.+)$/
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
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() ?? ''
}
function countOf(clause: string, kindAt: number): number {
/** 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]) || 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 (/\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'
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
@@ -130,7 +192,8 @@ export function weekdayOf(clause: string): number | 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}`)
// 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)) {
@@ -143,17 +206,25 @@ function parseClause(clause: string): Rule | null {
}
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) }
if (m) return contentRule(clause, kind, countOf(clause, m.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
@@ -170,6 +241,8 @@ export function parseInstruction(text: string): ParseResult {
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)
@@ -187,7 +260,8 @@ export function parseInstruction(text: string): ParseResult {
if (at >= 0) r.topic = text.slice(at, at + r.topic.length)
}
}
return language ? { rules, unknown, language } : { rules, unknown }
const notes = notesOf(text.toLowerCase())
return language ? { rules, unknown, language, notes } : { rules, unknown, notes }
}
export function weeklyTarget(rule: ContentRule): number {
@@ -209,10 +283,11 @@ export function describeRule(rule: Rule, from?: string): string {
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: kindLabel(rule.kind),
kind: rule.kindGuessed ? t('rule.anyKind') : kindLabel(rule.kind),
count: rule.count,
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
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')),