Turn Instruction into a chat with an agent that asks

The Instruction page is now a conversation with the Express Agent. Each
message updates the draft plan, and when something is unclear (a part it
did not understand, a video count with no cadence, a schedule over 50 per
week, weekly research with no day, a missing topic) the agent asks right
in the chat, one question at a time, with replies to pick or type. Once
the plan is clear it offers to start the schedules and reports the result
in the chat. The plan stays editable beside the chat.

The chat text is also larger and darker than the mockup's 9-11px grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 11:23:47 +07:00
parent e00ffe9465
commit 2b37d80e26
7 changed files with 409 additions and 82 deletions

55
src/express/agent.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { EMPTY, nextQuestion, respond, type AgentState } from './agent'
import { t } from '../i18n'
const say = (...inputs: string[]) => inputs.reduce<AgentState>((s, input) => respond(s, input).state, EMPTY)
const rules = (s: AgentState) => s.items.map(i => i.rule)
describe('instruction agent', () => {
it('needs no question when the instruction is complete', () => {
const s = say('make 2 summaries per day about leadership')
expect(nextQuestion(s)).toBeNull()
expect(respond(s, 'yes').approve).toBe(true)
})
it('asks for a topic, and takes the reply as the topic', () => {
const s = say('make 3 quizzes per week')
expect(nextQuestion(s)?.type).toBe('topic')
expect(rules(say('make 3 quizzes per week', 'negotiation'))[0]).toMatchObject({ topic: 'negotiation' })
})
it('stops asking for a topic once the whole Brain is chosen', () => {
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))).toBeNull()
})
it('asks about a video count that has no cadence', () => {
const s = say('3 videos per week about onboarding')
expect(nextQuestion(s)?.type).toBe('video')
expect(rules(say('3 videos per week about onboarding', t('agent.optPerDay')))[0]).toMatchObject({ count: 1, per: 'day' })
})
it('asks which day weekly research runs when none was named', () => {
const s = say('research every week about sales')
expect(nextQuestion(s)?.type).toBe('weekday')
const answered = say('research every week about sales', 'thursday')
expect(rules(answered)[0]).toMatchObject({ weekday: 3, weekdayGuessed: undefined })
expect(nextQuestion(answered)).toBeNull()
})
it('asks about parts it did not understand, and can skip them', () => {
const s = say('2 summaries per day about sales. hello there')
expect(nextQuestion(s)).toMatchObject({ type: 'unknown', text: 'hello there' })
expect(nextQuestion(say('2 summaries per day about sales. hello there', 'skip'))).toBeNull()
})
it('treats a reply with a frequency as a new request', () => {
const s = say('make 3 quizzes per week', '1 video per week about onboarding')
expect(rules(s).map(r => r.type === 'content' && r.kind)).toEqual(['quiz', 'video'])
})
it('caps a schedule that is over the weekly limit', () => {
const s = say('10 summaries per day about sales')
expect(nextQuestion(s)?.type).toBe('tooMany')
expect(rules(say('10 summaries per day about sales', '30 per week'))[0]).toMatchObject({ count: 30, per: 'week' })
})
})

144
src/express/agent.ts Normal file
View File

@@ -0,0 +1,144 @@
// The Instruction agent: a conversation on top of the deterministic parser. Each message updates the
// draft plan, and the agent asks about one gap at a time (a part it did not understand, a video
// cadence, a weekday, a topic) right in the chat, so nothing is left for the user to spot alone.
import { t, type Key } from '../i18n'
import { dayName, describeRule, parseInstruction, videoCadence, weekdayOf, weeklyTarget, type ContentRule, type ResearchRule, type Rule } from './parse'
export interface Item { id: number; rule: Rule }
export interface AgentState {
items: Item[]
/** Parts of the user's messages that matched no rule, asked about one by one. */
unknown: string[]
/** Questions settled without changing the plan, e.g. "use the whole Brain". */
settled: string[]
}
export const EMPTY: AgentState = { items: [], unknown: [], settled: [] }
export type Question =
| { key: string; type: 'unknown'; text: string }
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
| { key: string; type: 'topic'; ids: number[] }
/** 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] }
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 }
if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id }
}
const ids = s.items.filter(i => !i.rule.topic).map(i => i.id)
const key = `topic:${ids.join(',')}`
return ids.length && !s.settled.includes(key) ? { key, type: 'topic', ids } : null
}
const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(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
/** 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 'video':
return { text: t('agent.askVideo', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
case 'tooMany':
return { text: t('agent.askTooMany', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
case 'weekday':
return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) }
case 'topic':
return {
text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }),
options: [t('agent.optWholeBrain')],
}
}
}
/** A change made by hand in the plan; it replaces what the agent had guessed. */
export function editRule(s: AgentState, id: number, patch: Partial<ContentRule> | Partial<ResearchRule>): AgentState {
const guessed = {
// 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 } : {}),
}
return { ...s, items: s.items.map(i => (i.id === id ? { ...i, rule: { ...i.rule, ...patch, ...guessed } as Rule } : i)) }
}
export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, items: s.items.filter(i => i.id !== id) })
export interface Turn { state: AgentState; reply: string; approve?: boolean }
const YES = /^(ya|iya|yes|y|ok|oke|okay|okey|setuju|approve|lanjut|gas|sip|go|boleh)\b/
const SKIP = /^(skip|lewati|lewat|abaikan|ignore|no|nope|tidak|nggak|gak|enggak|ga)$/
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/
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.
function instruct(s: AgentState, text: string): Turn | null {
const { rules, unknown } = parseInstruction(text)
if (!rules.length) return null
return {
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown] },
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }),
}
}
function updated(s: AgentState, id: number, patch: Partial<ContentRule> | Partial<ResearchRule>): Turn {
const state = editRule(s, id, patch)
return { state, reply: t('agent.updated', { rule: describeRule(ruleById(state, id)) }) }
}
// Reads a reply to the agent's question; null when it does not answer it.
function answer(q: Question, s: AgentState, text: string, lower: string, is: (k: Key) => boolean): Turn | null {
if (q.type === 'unknown') {
if (SKIP.test(lower) || is('agent.optSkip')) return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.skipped', { part: q.text }) }
return instruct({ ...s, unknown: s.unknown.slice(1) }, text)
}
if (q.type === 'topic') {
if (WHOLE.test(lower) || SKIP.test(lower) || is('agent.optWholeBrain')) return { state: { ...s, settled: [...s.settled, q.key] }, reply: t('agent.wholeBrain') }
// A reply with a frequency is a new request, not a topic.
if (DAILY.test(lower) || WEEKLY.test(lower) || /\bper\b/.test(lower)) return null
const topic = text.replace(/^(about|regarding|tentang|soal|mengenai|topik|topic)\s+/i, '').replace(/[.!]+$/, '').trim()
if (!topic) return null
return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) }
}
if (REMOVE.test(lower) || is('agent.optRemove')) {
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) }
}
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') {
const n = Number(lower.match(/\d+/)?.[0])
if (!n) return null
return per === 'day' ? updated(s, q.id, { count: Math.min(n, 7), per }) : updated(s, q.id, { count: Math.min(n, 50), per: 'week' })
}
// weekday: "every day" turns it into daily research instead.
if (DAILY.test(lower)) return updated(s, q.id, { cadence: 'daily' })
const day = weekdayOf(lower) ?? [0, 1, 2, 3, 4, 5, 6].find(d => lower === dayName(d).toLowerCase())
return day === undefined || day === null ? null : updated(s, q.id, { weekday: day })
}
/** The agent's turn after the user says something, typed or picked from the options. */
export function respond(s: AgentState, input: string): Turn {
const text = input.trim()
const lower = text.toLowerCase()
const is = (key: Key) => lower === t(key).toLowerCase()
if (is('agent.optReset')) return { state: EMPTY, reply: t('agent.reset') }
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') }
}
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

@@ -61,6 +61,8 @@ export interface ResearchRule {
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
@@ -87,13 +89,13 @@ function countOf(clause: string, kindAt: number): number {
return (word && NUMBER_WORDS[word]) || 1
}
function perOf(clause: string): Per | 'month' {
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'
}
function weekdayOf(clause: string): number | null {
export function weekdayOf(clause: string): number | null {
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
return null
}
@@ -108,7 +110,9 @@ function parseClause(clause: string): Rule | null {
const per = perOf(clause)
const day = weekdayOf(clause)
const cadence = per === 'day' && day === null ? 'daily' : 'weekly'
return { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true }
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)