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:
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user