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:
@@ -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')
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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