Show the Brain beside the AI chat and make schedules from chosen material
The Brain and AI page lists Brain material (failed documents hidden) with search, open, delete and per-material or select-all ticks. New schedules use only the ticked material, kept through topic edits and resumes; nothing ticked means the whole Brain. With material ticked, the agent skips the content topic question and names the material in the chat and plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,16 @@ describe('instruction agent', () => {
|
||||
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
|
||||
})
|
||||
|
||||
it('asks no topic for content when material is chosen, and names that material in the plan', () => {
|
||||
const s = { ...say('make 1 scorm per week'), sources: '“LLM”' }
|
||||
expect(nextQuestion(s)?.type).toBe('language')
|
||||
expect(respond({ ...EMPTY, sources: '“LLM”' }, 'make 1 scorm per week').reply).toMatch(/from “LLM”/)
|
||||
})
|
||||
|
||||
it('still asks what to research on the internet when material is chosen', () => {
|
||||
expect(nextQuestion({ ...say('research every day'), sources: '“LLM”' })?.type).toBe('topic')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -18,9 +18,11 @@ export interface AgentState {
|
||||
settled: string[]
|
||||
/** The language the content is written in, confirmed by the user; null until then. */
|
||||
language: OutputLanguage | null
|
||||
/** The material chosen in the Brain panel, as the plan names it; null for the whole Brain. */
|
||||
sources: string | null
|
||||
}
|
||||
|
||||
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null }
|
||||
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null, sources: null }
|
||||
|
||||
export type Question =
|
||||
| { key: string; type: 'unknown'; text: string }
|
||||
@@ -36,7 +38,8 @@ export function nextQuestion(s: AgentState): Question | null {
|
||||
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)
|
||||
// Chosen material already says what content is about; research still needs a topic to search the internet for.
|
||||
const ids = s.items.filter(i => !i.rule.topic && !(s.sources && i.rule.type === 'content')).map(i => i.id)
|
||||
const key = `topic:${ids.join(',')}`
|
||||
if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids }
|
||||
// Research only adds sources to the Brain, so the language matters once there is content to write.
|
||||
@@ -44,7 +47,8 @@ export function nextQuestion(s: AgentState): Question | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n')
|
||||
const describe = (s: AgentState, rule: Rule) => describeRule(rule, s.sources ?? undefined)
|
||||
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
|
||||
|
||||
@@ -54,14 +58,14 @@ export function ask(q: Question, s: AgentState): { text: string; options: string
|
||||
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')] }
|
||||
return { text: t('agent.askVideo', { rule: describe(s, 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')] }
|
||||
return { text: t('agent.askTooMany', { rule: describe(s, 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))) }),
|
||||
text: t('agent.askTopic', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
|
||||
options: [t('agent.optWholeBrain')],
|
||||
}
|
||||
case 'language':
|
||||
@@ -116,7 +120,7 @@ function instruct(s: AgentState, text: string): Turn | null {
|
||||
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(rules) }) + languageNote,
|
||||
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + languageNote,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +135,7 @@ function languageAnswer(lower: string): OutputLanguage | null {
|
||||
|
||||
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)) }) }
|
||||
return { state, reply: t('agent.updated', { rule: describe(state, ruleById(state, id)) }) }
|
||||
}
|
||||
|
||||
// Reads a reply to the agent's question; null when it does not answer it.
|
||||
@@ -154,7 +158,7 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
|
||||
}
|
||||
|
||||
if (REMOVE.test(lower) || is('agent.optRemove')) {
|
||||
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) }
|
||||
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describe(s, 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
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface ContentRule {
|
||||
monthly?: number
|
||||
/** The language the content is written in; unset means the UI language. */
|
||||
language?: OutputLanguage
|
||||
/** Made from material chosen in the Brain panel rather than the whole Brain. */
|
||||
chosen?: boolean
|
||||
}
|
||||
|
||||
export interface ResearchRule {
|
||||
@@ -200,7 +202,8 @@ export function videoCadence(rule: ContentRule): 'daily' | 'weekly' | null {
|
||||
|
||||
export const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key)
|
||||
|
||||
export function describeRule(rule: Rule): string {
|
||||
/** `from` names the chosen material the rule is made from; without it, content comes from the whole Brain. */
|
||||
export function describeRule(rule: Rule, from?: string): string {
|
||||
const about = rule.topic ? t('rule.about', { topic: rule.topic }) : ''
|
||||
if (rule.type === 'research') {
|
||||
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) })
|
||||
@@ -210,7 +213,9 @@ export function describeRule(rule: Rule): string {
|
||||
kind: kindLabel(rule.kind),
|
||||
count: rule.count,
|
||||
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
|
||||
about: about || t('rule.wholeBrain'),
|
||||
about: about || (from ?? (rule.chosen ? t('rule.chosenMaterial') : null)
|
||||
? t('rule.from', { source: from ?? t('rule.chosenMaterial') })
|
||||
: t('rule.wholeBrain')),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,17 @@ describe('content language', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('chosen material', () => {
|
||||
it('marks a schedule made from chosen material in its brief, and reads it back as chosen', () => {
|
||||
const r = rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia')
|
||||
const config = configFor(r, 0, true)
|
||||
expect(config.instructions).toMatch(/Use only the Brain material chosen for this schedule\./)
|
||||
expect(configFor(r).instructions).not.toMatch(/chosen/)
|
||||
expect(ruleOf(agentFrom(config))).toEqual({ ...r, chosen: true })
|
||||
expect(ruleOf(agentFrom(configFor(r)))).toEqual(r)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ruleOf', () => {
|
||||
it.each([
|
||||
rule('summary', 3),
|
||||
|
||||
@@ -73,6 +73,11 @@ function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
|
||||
}
|
||||
|
||||
export function ruleOf(agent: ExpressAgent): Rule {
|
||||
const rule = baseRuleOf(agent)
|
||||
return rule.type === 'content' && usesChosen(agent) ? { ...rule, chosen: true } : rule
|
||||
}
|
||||
|
||||
function baseRuleOf(agent: ExpressAgent): Rule {
|
||||
const topic = agent.name.split(' · ').slice(2).join(' · ')
|
||||
if (agent.enabled_tools.includes('studio.create_video')) {
|
||||
const daily = agent.run_schedule?.cadence === 'daily'
|
||||
@@ -161,18 +166,23 @@ function goalText(rule: Rule): string {
|
||||
return `Create new learning ${KIND_TEXT[rule.kind]}${about} from the L&D team's Brain, without repeating content that already exists.`
|
||||
}
|
||||
|
||||
function instructionsFor(rule: Rule): string {
|
||||
// A schedule made from chosen material says so in its brief. That keeps it to those documents:
|
||||
// editing or resuming it attaches nothing else, while a whole-Brain schedule picks up new material.
|
||||
const CHOSEN_SENTENCE = 'Use only the Brain material chosen for this schedule.'
|
||||
const usesChosen = (agent: ExpressAgent) => !!agent.instructions?.includes(CHOSEN_SENTENCE)
|
||||
|
||||
function instructionsFor(rule: Rule, chosen = false): string {
|
||||
const language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage())
|
||||
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
|
||||
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
|
||||
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, chosen && CHOSEN_SENTENCE, language].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export function configFor(rule: Rule, videoCredits = 0) {
|
||||
export function configFor(rule: Rule, videoCredits = 0, chosen = false) {
|
||||
const budget = budgetFor(rule, videoCredits)
|
||||
const common = {
|
||||
name: scheduleName(rule),
|
||||
goal: goalText(rule),
|
||||
instructions: instructionsFor(rule),
|
||||
instructions: instructionsFor(rule, chosen),
|
||||
timezone: TIMEZONE,
|
||||
run_budget: budget.run,
|
||||
monthly_budget: budget.monthly,
|
||||
@@ -227,21 +237,23 @@ export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule {
|
||||
return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50
|
||||
}
|
||||
|
||||
// The production corpus is the agent's own sources, so every ready Brain document is attached.
|
||||
async function attachBrain(agentId: number) {
|
||||
const docs = (await listDocuments()).filter(d => d.knowledge === 'ready')
|
||||
for (const d of docs) {
|
||||
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: d.docId } })
|
||||
// The production corpus is the agent's own sources: the chosen documents, or every ready Brain document.
|
||||
async function attachBrain(agentId: number, chosen?: number[]) {
|
||||
const ids = chosen ?? (await listDocuments()).filter(d => d.knowledge === 'ready').map(d => d.docId)
|
||||
for (const id of ids) {
|
||||
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } })
|
||||
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
|
||||
}
|
||||
return docs.length
|
||||
return ids.length
|
||||
}
|
||||
|
||||
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
|
||||
/** With `chosen` document ids the schedule uses only those; without, the whole Brain. */
|
||||
export async function createSchedule(rule: Rule, chosen?: number[]): Promise<ExpressAgent> {
|
||||
const only = chosen?.length ? chosen : undefined
|
||||
// A video schedule's run budget is today's price of one video, so it is fetched first.
|
||||
const credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0
|
||||
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits) })
|
||||
await attachBrain(agent.id)
|
||||
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits, !!only) })
|
||||
await attachBrain(agent.id, only)
|
||||
// Activating approves the production rules as they stand; until then nothing runs.
|
||||
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
|
||||
}
|
||||
@@ -249,15 +261,17 @@ export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
|
||||
/** Changing a rule changes what was approved, so the agent is re-activated right after. */
|
||||
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
|
||||
const rule = { ...schedule.rule, topic: topic.trim() }
|
||||
const { name, goal, instructions } = configFor(rule)
|
||||
const chosen = usesChosen(schedule.agent)
|
||||
const { name, goal, instructions } = configFor(rule, 0, chosen)
|
||||
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
|
||||
await attachBrain(schedule.agent.id)
|
||||
if (!chosen) await attachBrain(schedule.agent.id)
|
||||
return request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
|
||||
export async function resumeSchedule(id: number) {
|
||||
await attachBrain(id)
|
||||
export async function resumeSchedule(schedule: Schedule) {
|
||||
const id = schedule.agent.id
|
||||
if (!usesChosen(schedule.agent)) await attachBrain(id)
|
||||
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
|
||||
}
|
||||
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })
|
||||
|
||||
Reference in New Issue
Block a user