// Each Express schedule is one engine agent. Content rules use the engine's recurring production // (content_goal: weekly plans with novelty checks, started automatically); research and video rules // use run_schedule (one run per day or week; a video run renders one video). import { opKey, request } from '../api/client' import { listDocuments, type Agent, type RunSchedule } from '../api/engine' import { getLang, t } from '../i18n' import { KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget, type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule, } from './parse' const AGENTS = '/organizations/current/agents' export const PREFIX = 'Express · ' const TIMEZONE = 'Asia/Jakarta' export interface ContentGoal { enabled: boolean weekly_target: number execution_mode: 'automatic' | 'review' production_cadence: 'spread' | 'weekly' | 'manual' production_hour: number production_minute: number production_weekday: number weekly_budget: number } export interface VideoOptions { max_scenes: number max_duration_seconds: number art_style: 'realistic' | 'cartoon' | 'anime' | 'watercolor' | '3d' language: OutputLanguage } export interface ExpressAgent extends Agent { next_run_at: string | null enabled_tools: string[] material_outputs: { kind: ContentKind; options: Record }[] content_goal: ContentGoal | null video_options: VideoOptions | null auto_approve_sources: boolean run_budget: number monthly_budget: number created_at: string } export interface Schedule { agent: ExpressAgent rule: Rule active: boolean } // The per-day/per-week wording is not part of the engine config, so it travels in the agent name. // Names stay in English whatever the UI language, so they parse the same for every viewer. function scheduleName(rule: Rule): string { const topic = rule.topic ? ` · ${rule.topic}` : '' if (rule.type === 'research') return `${PREFIX}Research ${rule.cadence}${topic}`.slice(0, 120) return `${PREFIX}${rule.count} ${rule.kind} per ${rule.per}${topic}`.slice(0, 120) } const isKind = (k: string | undefined): k is ContentKind => KINDS.includes(k as ContentKind) /** A rule with no language chosen is written in the UI language, as before languages could be chosen. */ export const defaultLanguage = (): OutputLanguage => (getLang() === 'id' ? 'Bahasa Indonesia' : 'English') // How the agent's English brief names each language; read back by languageOfAgent. const LANGUAGE_TEXT: Record = { English: 'English', 'Bahasa Indonesia': 'Bahasa Indonesia', 中文: 'Simplified Chinese (中文)', } const languageSentence = (l: OutputLanguage) => `Write all content in ${LANGUAGE_TEXT[l]}.` function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined { return agent.video_options?.language ?? LANGUAGES.find(l => agent.instructions?.includes(languageSentence(l))) } 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' return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic, language: languageOfAgent(agent) } } if (agent.content_goal?.enabled && agent.material_outputs[0]) { // " per hari" comes from schedules created before names were in English. const per: Per = / per (day|hari)\b/.test(agent.name) ? 'day' : 'week' const target = agent.content_goal.weekly_target // Audio and SCORM are Express kinds made of Compose outputs, so the name carries the kind. const named = agent.name.match(/^Express · \d+ (\w+) per /)?.[1] const kind = isKind(named) ? named : agent.material_outputs[0].kind return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic, language: languageOfAgent(agent) } } const s = agent.run_schedule ?? { cadence: 'off' } return { type: 'research', cadence: s.cadence === 'weekly' ? 'weekly' : 'daily', weekday: s.weekday ?? 0, topic, autoApprove: agent.auto_approve_sources } } // Archived schedules are gone from the Schedule page, but what they produced still belongs on Results. export async function expressAgents({ archived = false } = {}): Promise { const agents = await request(AGENTS) return agents .filter(a => a.name.startsWith(PREFIX) && (archived || a.status !== 'archived')) .sort((a, b) => b.created_at.localeCompare(a.created_at)) .map(agent => ({ agent, rule: ruleOf(agent), active: agent.status === 'active' })) } export const listSchedules = () => expressAgents() /** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */ export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video' // The engine plans each production piece from a pair of documents, so a content schedule // with fewer ready sources than this cannot plan anything. Video and research runs need one. export const MIN_PRODUCTION_SOURCES = 2 export const needsTwoSources = (rule: Rule) => !isRunSchedule(rule) const TOO_FEW_SOURCES = 'At least two ready sources with complementary evidence are required' /** The engine's planner refusal, said in the UI language; any other message as it came. */ export const productionError = (message: string) => (message.includes(TOO_FEW_SOURCES) ? t('sch.tooFewSources', { n: MIN_PRODUCTION_SOURCES }) : message) // Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene). export const VIDEO = { scenes: 4, seconds: 40 } export interface VideoQuote { max_scenes: number; max_total_seconds: number; max_credits: number } /** The current price ceiling of one video; the engine pins and rechecks it when a run starts. */ export const videoQuote = () => request(`${AGENTS}/video-quote?scenes=${VIDEO.scenes}&duration_seconds=${VIDEO.seconds}`) // Credit limits. The engine requires run ≤ weekly ≤ monthly; a run is one planned item, one research // pass, or one video. A SCORM item is two Compose outputs (lesson + quiz), so it gets twice the room. export function budgetFor(rule: Rule, videoCredits = 0) { if (rule.type === 'research') { const run = 50 return { run, weekly: null, monthly: rule.cadence === 'daily' ? run * 31 : run * 5 } } if (rule.kind === 'video') { // A little headroom over today's quote, so a small price change does not stop the schedule. const run = Math.max(1, Math.ceil((videoCredits * 11) / 10)) return { run, weekly: null, monthly: videoCadence(rule) === 'daily' ? run * 31 : run * 5 } } const perItem = rule.kind === 'scorm' ? 40 : 20 const weekly = Math.max(rule.kind === 'scorm' ? 120 : 60, weeklyTarget(rule) * perItem) return { run: Math.min(rule.kind === 'scorm' ? 60 : 30, weekly), weekly, monthly: weekly * 5 } } const KIND_TEXT: Record = { summary: 'summaries', quiz: 'quizzes', flashcards: 'flashcard sets', roleplay: 'roleplay scenarios', simulator: 'simulations', slide_deck: 'slide decks', video: 'short explainer videos', audio: 'audio lessons', scorm: 'e-learning modules (a lesson with a quiz that checks it)', } // What each Express kind asks of the engine's Compose outputs. const KIND_BRIEF: Partial> = { audio: 'Each summary is the narration script of an audio lesson of about 3 to 5 minutes. Write it to be read aloud: ' + 'conversational, short sentences, a friendly opening and a short recap at the end. No headings, bullet points, tables or Markdown.', scorm: 'Each lesson summary and its quiz form one e-learning module: the quiz checks exactly what the summary teaches.', video: 'Each video explains one practical idea from the Brain in plain language for employees.', } function outputsFor(kind: ContentKind): ExpressAgent['material_outputs'] { if (kind === 'audio') return [{ kind: 'summary', options: {} }] if (kind === 'scorm') return [{ kind: 'summary', options: {} }, { kind: 'quiz', options: { question_count: 10, question_type: 'multiple_choice_only' } }] return [{ kind, options: {} }] } // The agent's brief is in English; the content language is the one chosen in the Instruction chat. // The engine's content production has no language field, so it is part of the instructions. function goalText(rule: Rule): string { const about = rule.topic ? ` about ${rule.topic}` : '' if (rule.type === 'research') { return `Research the public internet${about || ' for learning topics relevant to the L&D team'} and add new, relevant public sources to the team's Brain.` } return `Create new learning ${KIND_TEXT[rule.kind]}${about} from the L&D team's Brain, without repeating content that already exists.` } // 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, chosen && CHOSEN_SENTENCE, language].filter(Boolean).join(' ') } 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, chosen), timezone: TIMEZONE, run_budget: budget.run, monthly_budget: budget.monthly, } if (rule.type === 'research') { return { ...common, enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url'], research_options: { max_results: 5 }, // Express is hands-off: accepted sources go straight into the Brain unless the user unticks this. auto_approve_sources: rule.autoApprove, run_schedule: researchSchedule(rule), } } if (rule.kind === 'video') { return { ...common, enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'studio.create_video'], video_options: { max_scenes: VIDEO.scenes, max_duration_seconds: VIDEO.seconds, art_style: 'realistic', language: rule.language ?? defaultLanguage(), } satisfies VideoOptions, run_schedule: { cadence: videoCadence(rule) ?? 'weekly', hour: 9, minute: 0, weekday: 0 } satisfies RunSchedule, } } return { ...common, enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'compose.create_material'], material_outputs: outputsFor(rule.kind), content_goal: { enabled: true, weekly_target: weeklyTarget(rule), execution_mode: 'automatic', // "spread": the engine picks delivery days; a daily rule (target ≥ 7) produces every day. production_cadence: 'spread', production_hour: 9, production_minute: 0, weekly_budget: budget.weekly!, } satisfies Partial, } } const researchSchedule = (rule: ResearchRule): RunSchedule => ({ cadence: rule.cadence, hour: 7, minute: 0, weekday: rule.weekday }) export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule { if (rule.type === 'research') return true if (rule.count < 1) return false if (rule.kind === 'video') return videoCadence(rule) !== null return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50 } // 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 ids.length } /** With `chosen` document ids the schedule uses only those; without, the whole Brain. */ export async function createSchedule(rule: Rule, chosen?: number[]): Promise { 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(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(`${AGENTS}/${agent.id}/activate`, { method: 'POST' }) } /** Changing a rule changes what was approved, so the agent is re-activated right after. */ export async function updateTopic(schedule: Schedule, topic: string): Promise { const rule = { ...schedule.rule, topic: topic.trim() } 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 } }) if (!chosen) await attachBrain(schedule.agent.id) return request(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' }) } export const pauseSchedule = (id: number) => request(`${AGENTS}/${id}/pause`, { method: 'POST' }) export async function resumeSchedule(schedule: Schedule) { const id = schedule.agent.id if (!usesChosen(schedule.agent)) await attachBrain(id) return request(`${AGENTS}/${id}/activate`, { method: 'POST' }) } export const archiveSchedule = (id: number) => request(`${AGENTS}/${id}/archive`, { method: 'POST' }) /** Starts one run of a research or video schedule now, outside its daily or weekly slot. */ export const runNow = (id: number) => request<{ id: number; status: string }>(`${AGENTS}/${id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`run-${id}`) }, json: {} }) // ── Status and history ── export interface ProductionStatus { enabled: boolean goal_status: string | null planning_available: boolean period: { week_start: string; target: number; ready: number; claimed: number; failed: number; shortfall: number; spent_credits: number } | null plans: ContentPlan[] } export interface ContentPlan { id: number revision: number status: 'quoted' | 'planning' | 'review' | 'approved' | 'running' | 'completed' | 'paused' | 'failed' | 'cancelled' target: number error_code: string | null planning_quote: { credits: number | null } items: PlanItem[] } export interface PlanItem { id: number status: 'proposed' | 'approved' | 'queued' | 'generating' | 'ready' | 'blocked' | 'failed' | 'skipped' | 'cancelled' | 'rejected' run_id: number | null attempt_count: number title: string required_outputs: { kind: ContentKind }[] } const DROPPED = new Set(['skipped', 'cancelled', 'rejected']) // One plan's progress, for the Schedule row and the Results banner. export function planProgress(plan: ContentPlan) { const items = plan.items.filter(i => !DROPPED.has(i.status)) return { total: items.length, ready: items.filter(i => i.status === 'ready').length, failed: items.filter(i => i.status === 'failed' || i.status === 'blocked').length, // The engine may find fewer new topics than asked for; the rest is a shortfall, not an error. shortfall: plan.status === 'planning' ? 0 : Math.max(0, plan.target - items.length), } } export const productionStatus = (id: number) => request(`${AGENTS}/${id}/content`) // Produce now = the same plan the 09:00 tick would create, just earlier. The engine's tick then // approves and starts it within a minute because Express schedules run in automatic mode. export const quoteProduction = (id: number, target: number) => request(`${AGENTS}/${id}/content/planning-quotes`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`produce-${id}`) }, json: { target } }) export const confirmProduction = (id: number, plan: ContentPlan) => request(`${AGENTS}/${id}/content/plans/${plan.id}/confirm-planning`, { method: 'POST', json: { revision: plan.revision } }) // Retrying a failed item = a fresh generation quote for that item alone, then starting it. The engine // allows three attempts per item and only by the person who started the first one. export const MAX_ATTEMPTS = 3 export async function retryItem(agentId: number, planId: number, itemId: number) { const plan = (await productionStatus(agentId)).plans.find(p => p.id === planId) if (!plan) throw new Error('The production plan is no longer available') const base = `${AGENTS}/${agentId}/content/plans/${planId}` const quote = await request<{ revision: number; quote_signature: string }>(`${base}/items/${itemId}/retry-quote`, { method: 'POST', json: { revision: plan.revision } }) return request(`${base}/start`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`retry-${itemId}`) }, json: { revision: quote.revision, quote_signature: quote.quote_signature }, }) } export const PLAN_ACTIVE = new Set(['planning', 'review', 'approved', 'running']) export interface RunSummary { id: number status: string trigger_kind: string safe_error_code: string | null budget_spent: number | null has_readiness_issues: boolean /** Research runs: what came of the sources found, e.g. no_new_sources when nothing was kept. */ source_outcome?: string | null created_at: string finished_at: string | null } export const listRuns = (id: number) => request(`${AGENTS}/${id}/runs`) // What stops a run with knowledge_not_ready: the schedule's own material that is not ready (the whole // Library only for an agent with no material). This is the current state, not the run's. export interface ReadinessIssue { kind: 'document' | 'compose_set' resource_id: number title: string reason: string action_url: string } export const knowledgeReadiness = (id: number) => request<{ ready: boolean; issues: ReadinessIssue[] }>(`${AGENTS}/${id}/knowledge-readiness`)