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

@@ -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')),