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:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

316
src/express/schedules.ts Normal file
View 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`)