Initial commit: Learning Lab Express
Includes fixes found while reviewing the app: - Results: open each result by a type+agent+id key, so a video and a material draft with the same id no longer open each other's popup - AuthGate: clear the query cache during render, before pages build their queries, and remount pages on organization switch (pages no longer stay on skeletons for up to a minute after sign-in or refresh) - Instruction: keep only failed rules after a partial approve, so retrying does not create the successful schedules twice; drop the monthly note once count or frequency is edited - Parser/schedules: never schedule a count below one - Audio: ignore the end event of a cancelled utterance when changing speed - Downloads: revoke the object URL later so Safari/Firefox can read it - Results: refetch SCORM content when any part's revision changes; show a failed video as failed instead of "still rendering" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
316
src/express/schedules.ts
Normal file
316
src/express/schedules.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
// 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 } from '../i18n'
|
||||
import { KINDS, SCHEDULABLE, videoCadence, weeklyTarget, type ContentKind, type ContentRule, 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: 'English' | 'Bahasa Indonesia'
|
||||
}
|
||||
|
||||
export interface ExpressAgent extends Agent {
|
||||
next_run_at: string | null
|
||||
enabled_tools: string[]
|
||||
material_outputs: { kind: ContentKind; options: Record<string, unknown> }[]
|
||||
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)
|
||||
|
||||
export function ruleOf(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 }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
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<Schedule[]> {
|
||||
const agents = await request<ExpressAgent[]>(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'
|
||||
|
||||
// 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<VideoQuote>(`${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<ContentKind, string> = {
|
||||
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<Record<ContentKind, string>> = {
|
||||
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 follows the UI language at creation time.
|
||||
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.`
|
||||
}
|
||||
|
||||
function instructionsFor(rule: Rule): string {
|
||||
const language = getLang() === 'id' ? 'Write all content in Bahasa Indonesia.' : 'Write all content in English.'
|
||||
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
|
||||
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export function configFor(rule: Rule, videoCredits = 0) {
|
||||
const budget = budgetFor(rule, videoCredits)
|
||||
const common = {
|
||||
name: scheduleName(rule),
|
||||
goal: goalText(rule),
|
||||
instructions: instructionsFor(rule),
|
||||
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: getLang() === 'id' ? 'Bahasa Indonesia' : 'English',
|
||||
} 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<ContentGoal>,
|
||||
}
|
||||
}
|
||||
|
||||
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, 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 } })
|
||||
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
|
||||
}
|
||||
return docs.length
|
||||
}
|
||||
|
||||
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
|
||||
// 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)
|
||||
// Activating approves the production rules as they stand; until then nothing runs.
|
||||
return request<ExpressAgent>(`${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<ExpressAgent> {
|
||||
const rule = { ...schedule.rule, topic: topic.trim() }
|
||||
const { name, goal, instructions } = configFor(rule)
|
||||
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
|
||||
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)
|
||||
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
|
||||
}
|
||||
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${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
|
||||
title: string
|
||||
required_outputs: { kind: ContentKind }[]
|
||||
}
|
||||
|
||||
const DROPPED = new Set<PlanItem['status']>(['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<ProductionStatus>(`${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<ContentPlan>(`${AGENTS}/${id}/content/planning-quotes`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`produce-${id}`) }, json: { target } })
|
||||
|
||||
export const confirmProduction = (id: number, plan: ContentPlan) =>
|
||||
request<ContentPlan>(`${AGENTS}/${id}/content/plans/${plan.id}/confirm-planning`, { method: 'POST', json: { revision: plan.revision } })
|
||||
|
||||
export const PLAN_ACTIVE = new Set<ContentPlan['status']>(['planning', 'review', 'approved', 'running'])
|
||||
|
||||
export interface RunSummary {
|
||||
id: number
|
||||
status: string
|
||||
trigger_kind: string
|
||||
safe_error_code: string | null
|
||||
budget_spent: number | null
|
||||
created_at: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
|
||||
Reference in New Issue
Block a user