Files
learning-lab-express/src/express/results.ts
Iqbal 977a8fafc4 Pace engine reads under the proxy's rate limit
The proxy allows 10 requests a second per address and answers the rest
503. Limiting how many reads run at once still sent 40-50 a second, since
each answers in about 100 ms. Reads now share one pace of 8 a second,
actions skip the queue, and a 503 pauses every read for 2 seconds.
Results and Schedule share each schedule's production status, and an
archived schedule is read once until an action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:06:40 +07:00

430 lines
21 KiB
TypeScript

// Results = everything the Express agents produced: Compose materials (material-drafts, titled by the
// content plan item that ordered them), rendered videos (video-drafts) and web sources found by
// research (source-drafts). Audio and SCORM are Express kinds built from Compose outputs: an audio
// lesson is a narration summary, a SCORM module is the summary + quiz pair one plan item produced.
import { EngineError, request, sleep } from '../api/client'
import { t } from '../i18n'
import { kindLabel, type ContentKind, type Rule } from './parse'
import { expressAgents, forgetProductionReads, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, runNow, type PlanItem, type Schedule } from './schedules'
const AGENTS = '/organizations/current/agents'
export type MaterialStatus = 'preparing' | 'generating' | 'ready' | 'failed' | 'accepted' | 'rejected'
interface RawMaterial {
id: number
agent_id: number
run_id: number
output_kind: ContentKind
status: MaterialStatus
revision: number
compose_set_id: number | null
sources: { document_id: number; title: string }[]
created_at: string
}
/** One engine draft behind a result; a SCORM module has two (lesson and quiz). */
export interface Part { id: number; revision: number; kind: ContentKind; status: MaterialStatus }
export interface Material {
id: number
agentId: number
/** What the user asked for, which for audio and SCORM is not the engine's output kind. */
kind: ContentKind
status: MaterialStatus
revision: number
parts: Part[]
planTitle: string | null
composeSetId: number | null
sources: string[]
createdAt: string
rule: Rule
/** How to produce it again, when the engine allows another attempt: the plan item, or (video) a new run. */
retry?: { planId: number; itemId: number } | { run: true }
video?: { url: string | null; progress: number; credits: number | null }
}
// Labels are resolved at render time so they follow the UI language.
export const titleOf = (m: Material) => m.planTitle ?? [kindLabel(m.kind), m.rule.topic].filter(Boolean).join(' · ')
// Ids come from separate engine tables (material drafts, video drafts, plan items) and repeat across them.
export const materialKey = (m: Material) => `${m.kind === 'video' ? 'video' : 'material'}-${m.agentId}-${m.id}`
export type MaterialContent = Record<string, unknown>
export interface FoundSource {
id: number
agentId: number
title: string
url: string | null
status: 'pending' | 'accepted' | 'rejected' | string
revision: number
preview: string
documentId: number | null
createdAt: string
rule: Rule
}
interface RawSource {
id: number
agent_id: number
title: string
source_url: string | null
status: string
revision: number
preview: string
document_id: number | null
captured_at: string
}
// The least finished part decides: a module is ready only when both its lesson and quiz are.
const ORDER: MaterialStatus[] = ['failed', 'rejected', 'preparing', 'generating', 'ready', 'accepted']
const combined = (parts: Part[]) => ORDER.find(s => parts.some(p => p.status === s)) ?? 'preparing'
// The engine can stop a plan item without updating its drafts (e.g. a run paused on a changed
// quote leaves the draft "preparing" for good), so a stopped item overrides a draft still at work.
const STOPPED = new Set<PlanItem['status']>(['failed', 'blocked', 'cancelled', 'skipped'])
export const withPlanItem = (status: MaterialStatus, item: PlanItem['status'] | undefined): MaterialStatus =>
item && STOPPED.has(item) && (status === 'preparing' || status === 'generating') ? 'failed' : status
async function materialsOf(schedule: Schedule): Promise<Material[]> {
if (schedule.rule.type === 'content' && schedule.rule.kind === 'video') return videosOf(schedule)
const { agent, rule } = schedule
const kind = rule.type === 'content' ? rule.kind : 'summary'
const [drafts, status] = await Promise.all([
request<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`),
// Without its plan a schedule still has drafts; a busy engine fails the whole read instead, so
// the last complete one is shown rather than drafts missing their titles and planned pieces.
productionStatus(agent.id).catch(e => { if (passing(e)) throw e; return null }),
])
const items = new Map<number, PlanItem & { planId: number }>()
for (const plan of status?.plans ?? []) {
for (const item of plan.items ?? []) if (item.run_id) items.set(item.run_id, { ...item, planId: plan.id })
}
// One run makes one result; its drafts are its parts (one, or the lesson + quiz of a SCORM module).
const byRun = new Map<number, RawMaterial[]>()
for (const d of drafts) byRun.set(d.run_id, [...(byRun.get(d.run_id) ?? []), d])
const made = [...byRun.values()].map((group): Material => {
// The lesson comes first: it carries the title, sources and Compose set of the whole module.
group.sort((a, b) => Number(b.output_kind === 'summary') - Number(a.output_kind === 'summary'))
const [d] = group
const item = items.get(d.run_id)
const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status }))
return {
id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind,
status: withPlanItem(combined(parts), item?.status), revision: d.revision, parts,
planTitle: item?.title ?? null,
retry: item?.status === 'failed' && item.attempt_count < MAX_ATTEMPTS ? { planId: item.planId, itemId: item.id } : undefined,
composeSetId: d.compose_set_id, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
}
})
// Planned pieces have no draft until generation starts; show them as in progress so a running
// production is visible here from the moment the topics are chosen.
const started = new Set(drafts.map(d => d.run_id))
const planned = (status?.plans ?? []).filter(p => PLAN_ACTIVE.has(p.status)).flatMap(p => p.items)
.filter(i => ['proposed', 'approved', 'queued', 'generating'].includes(i.status) && !(i.run_id && started.has(i.run_id)))
.map((i): Material => ({
id: -i.id, agentId: agent.id, kind, status: 'preparing', revision: 0, parts: [],
planTitle: i.title, composeSetId: null, sources: [], createdAt: new Date().toISOString(), rule,
}))
return [...planned, ...made]
}
interface RawVideo {
id: number
run_id: number
status: string
title: string | null
progress_percentage: number
video_url: string | null
sources: { document_id: number; title: string }[]
actual_credits: number | null
created_at: string
}
// Video drafts move through script, storyboard, clips and render before they are ready to review.
function videoStatus(s: string): MaterialStatus {
if (s === 'ready' || s === 'accepted' || s === 'rejected') return s
if (s === 'failed' || s === 'cancelled') return 'failed'
return 'generating'
}
async function videosOf({ agent, rule, active }: Schedule): Promise<Material[]> {
const drafts = await request<RawVideo[]>(`${AGENTS}/${agent.id}/video-drafts`)
// A video draft cannot be retried itself; a new run of its schedule makes it again. Offered on the
// newest draft only, so older failures don't each start another (paid) run.
const newest = Math.max(...drafts.map(d => d.id))
return drafts.map(d => {
const status = videoStatus(d.status)
return {
retry: status === 'failed' && active && d.id === newest ? { run: true } as const : undefined,
id: d.id, agentId: agent.id, kind: 'video', status, revision: 0,
parts: [{ id: d.id, revision: 0, kind: 'video', status }],
planTitle: d.title, composeSetId: null, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
video: { url: d.video_url, progress: d.progress_percentage, credits: d.actual_credits },
}
})
}
async function sourcesOf({ agent, rule }: Schedule): Promise<FoundSource[]> {
const drafts = await request<RawSource[]>(`${AGENTS}/${agent.id}/source-drafts`)
return drafts.map(d => ({
id: d.id, agentId: agent.id, title: d.title, url: d.source_url, status: d.status, revision: d.revision,
preview: d.preview, documentId: d.document_id, createdAt: d.captured_at, rule,
}))
}
// One schedule the engine cannot answer for (e.g. an archived agent) leaves its results out
// instead of failing them all; only when every schedule fails is that an error.
export function keepSettled<T>(results: PromiseSettledResult<T[]>[]): T[] {
const kept = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
const failed = results.filter(r => r.status === 'rejected')
if (failed.length) console.warn(`${failed.length} of ${results.length} schedules could not be loaded`, failed.map(r => r.reason))
if (results.length && failed.length === results.length) throw failed[0].reason
return kept
}
// Promise.allSettled over a function, running at most `limit` at a time. Results are loaded one
// request per schedule and polled; firing them all at once floods the engine, which then answers
// some with 503 and drops those schedules' results until the next poll.
export async function settleLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
const out: PromiseSettledResult<R>[] = new Array(items.length)
let next = 0
const worker = async () => {
while (next < items.length) {
const i = next++
try { out[i] = { status: 'fulfilled', value: await fn(items[i]) } }
catch (reason) { out[i] = { status: 'rejected', reason } }
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
return out
}
const PER_SCHEDULE_LIMIT = 4
// The proxy's rate limit (503/429), a 5xx or no answer at all passes; a 4xx (e.g. a deleted agent) does not.
const passing = (e: unknown) => e instanceof EngineError && (e.status === 0 || e.status === 429 || e.status >= 500)
const settledReads: Set<number>[] = []
/** After any action: an archived schedule's results (a decision on one) and production plans
* (a started or retried piece) may have changed, so the next poll reads them again. */
export function forgetReads() {
for (const s of settledReads) s.clear()
forgetProductionReads()
}
/** Wraps a per-schedule read so a passing failure does not change what the page shows: a busy
* engine is asked once more after a pause, and if it still fails, the schedule's results from the
* last poll that read them are used. Dropping them instead made every count on the page dip for
* one poll and come back on the next. */
export function remembering<T>(fn: (s: Schedule) => Promise<T[]>, pause = 3000) {
const last = new Map<number, T[]>()
const settled = new Set<number>()
settledReads.push(settled)
return async (s: Schedule): Promise<T[]> => {
// An archived schedule makes nothing new, so after one read it costs nothing until an action.
const archived = s.agent.status === 'archived'
if (archived && settled.has(s.agent.id)) return last.get(s.agent.id)!
try {
const read = await fn(s).catch(async e => {
if (!(e instanceof EngineError && (e.status === 503 || e.status === 429))) throw e
await sleep(pause)
return fn(s)
})
last.set(s.agent.id, read)
if (archived) settled.add(s.agent.id)
return read
} catch (e) {
const kept = last.get(s.agent.id)
if (kept && passing(e)) return kept
throw e
}
}
}
const readMaterials = remembering(materialsOf)
const readSources = remembering(sourcesOf)
// Results cost one request per schedule (every video schedule's drafts), so they are read once a
// minute, and a page opened or refocused within that minute reuses what was read. An action on a
// result or schedule still refreshes them at once.
export const RESULTS_POLL = { refetchInterval: 60_000, staleTime: 60_000 }
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
const schedules = await expressAgents({ archived: true })
const content = schedules.filter(s => s.rule.type === 'content')
const research = schedules.filter(s => s.rule.type === 'research')
// One shared budget: research runs after content rather than alongside it.
const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, readMaterials)
const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, readSources)
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) }
}
const draftContent = (m: Material, id: number) =>
request<{ content?: MaterialContent; output_id?: number }>(`${AGENTS}/${m.agentId}/material-drafts/${id}`)
// outputId is the generated Compose output (e.g. the SlideDeck id), which owns the file exports.
// A SCORM module merges its parts: the lesson's title and text with the quiz's questions.
export async function materialContent(m: Material): Promise<{ content: MaterialContent | null; outputId: number | null }> {
if (m.kind === 'video') return { content: { title: m.planTitle ?? '' }, outputId: null }
const [first, ...rest] = await Promise.all(m.parts.map(p => draftContent(m, p.id)))
if (!first?.content) return { content: null, outputId: null }
const content = { ...first.content }
for (const r of rest) if (r.content?.questions) content.questions = r.content.questions
return { content, outputId: first.output_id ?? null }
}
// The engine renders slide decks to PPTX itself; other kinds have no file export there.
export const exportPptx = (deckId: number) => request<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
export const retryMaterial = ({ agentId, retry }: Material) =>
retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId)
// Every part waiting for review gets the same decision, so a SCORM module is approved as one.
export async function decideMaterial(m: Material, decision: 'accept' | 'reject') {
for (const p of m.parts.filter(p => p.status === 'ready')) {
if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } })
else await request(`${AGENTS}/${m.agentId}/material-drafts/${p.id}/decision`, { method: 'POST', json: { expected_revision: p.revision, decision } })
}
}
// Accepting a source only adds it to the Library; its knowledge build is confirmed on the Brain page.
export const reviewSource = (s: FoundSource, decision: 'accept' | 'reject') =>
request(`${AGENTS}/${s.agentId}/source-drafts/${s.id}/review`, { method: 'POST', json: { expected_revision: s.revision, decision } })
export const isWorking = (m: Material) => m.status === 'preparing' || m.status === 'generating'
// ── Export ──
const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v])
export interface Question {
question: string
options: string[]
answer: string
explanation: string
/** Index of the correct option, or -1 when the answer is not one of the options (an essay). */
correct: number
}
// The engine gives the answer as the option text, its letter ("B", "B. …") or its index.
export function correctIndex(options: string[], answer: string): number {
const exact = options.findIndex(o => o.trim().toLowerCase() === answer.trim().toLowerCase())
if (exact >= 0) return exact
const letter = answer.trim().match(/^([A-Ha-h])(?:[.)\s:]|$)/)?.[1]
if (letter) {
const i = letter.toUpperCase().charCodeAt(0) - 65
if (i < options.length) return i
}
const index = /^\d+$/.test(answer.trim()) ? Number(answer) : -1
return index >= 0 && index < options.length ? index : -1
}
export function questionsOf(content: MaterialContent): Question[] {
return list(content.questions).map(raw => {
const q = (raw ?? {}) as Record<string, unknown>
const options = list(q.options ?? q.choices).map(o => (typeof o === 'object' && o ? str((o as Record<string, unknown>).text ?? (o as Record<string, unknown>).label) : str(o)))
const answer = str(q.correct_answer ?? q.answer)
return { question: str(q.question ?? q.text ?? q.prompt), options, answer, explanation: str(q.explanation), correct: correctIndex(options, answer) }
})
}
export interface Slide { title: string; bullets: string[]; notes: string }
export function slidesOf(content: MaterialContent): Slide[] {
return list(content.slides).map(raw => {
const s = (raw ?? {}) as Record<string, unknown>
const c = ((s.content ?? s) as Record<string, unknown>) ?? {}
return { title: str(c.title ?? s.title), bullets: list(c.bullets).map(str), notes: str(s.speaker_notes ?? s.notes) }
})
}
export const textOf = str
export const listOf = list
export function toMarkdown(m: Material, content: MaterialContent): string {
const out = [`# ${str(content.title) || titleOf(m)}`, '']
if (m.kind === 'scorm') out.push(str(content.text), '', `## ${t('res.quiz')}`, '')
if (m.kind === 'summary' || m.kind === 'audio') out.push(str(content.text))
else if (m.kind === 'quiz' || m.kind === 'scorm') {
questionsOf(content).forEach((q, i) => {
out.push(`${i + 1}. ${q.question}`, ...q.options.map(o => ` - ${o}`))
if (q.answer) out.push(` ${t('res.answer')}: ${q.answer}`)
if (q.explanation) out.push(` ${q.explanation}`)
out.push('')
})
} else if (m.kind === 'flashcards') {
for (const c of list(content.cards) as Record<string, unknown>[]) out.push(`**${str(c.front)}**`, str(c.back), '')
} else if (m.kind === 'slide_deck') {
if (content.description) out.push(str(content.description), '')
slidesOf(content).forEach((s, i) => out.push(`## ${i + 1}. ${s.title}`, ...s.bullets.map(b => `- ${b}`), ''))
} else {
for (const [key, value] of Object.entries(content)) {
if (key === 'title') continue
out.push(`## ${key.replace(/_/g, ' ')}`, ...list(value).map(v => (Array.isArray(value) ? `- ${str(v)}` : str(v))), '')
}
}
if (m.sources.length) out.push('', '---', `${t('res.sources')}: ${m.sources.join(', ')}`)
return out.join('\n').trim() + '\n'
}
export function download(name: string, data: string | Blob) {
const url = URL.createObjectURL(typeof data === 'string' ? new Blob([data], { type: 'text/markdown;charset=utf-8' }) : data)
const a = Object.assign(document.createElement('a'), { href: url, download: name })
a.click()
// Revoked later: Safari and Firefox may not have started reading the file when click() returns.
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
export const fileName = (m: Material, ext: 'md' | 'pptx' | 'txt' | 'zip' | 'mp4') =>
`${titleOf(m).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || m.kind}.${ext}`
// PDF without a PDF library: print a standalone page from a hidden iframe, so the browser's own
// "Save as PDF" lays out the text (real, selectable text with proper fonts and page breaks).
export function printPdf(title: string, bodyHtml: string) {
const frame = Object.assign(document.createElement('iframe'), { title: 'print' })
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0'
document.body.append(frame)
const doc = frame.contentDocument!
doc.open()
doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>${PRINT_CSS}</style></head><body>${bodyHtml}</body></html>`)
doc.close()
const win = frame.contentWindow!
// Remove the frame once the print dialog is done; the timeout covers browsers without afterprint.
const cleanup = () => setTimeout(() => frame.remove(), 500)
win.addEventListener('afterprint', cleanup, { once: true })
setTimeout(() => { win.focus(); win.print(); cleanup() }, 100)
}
const escapeHtml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
const PRINT_CSS = `
@page{size:A4;margin:18mm 16mm}
*{box-sizing:border-box}
body{font:11pt/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1d2a3b;margin:0}
header{border-bottom:2px solid #2563eb;padding-bottom:10px;margin-bottom:18px}
header small{display:block;font-size:8.5pt;letter-spacing:.08em;text-transform:uppercase;color:#2563eb;font-weight:600}
header p{margin:6px 0 0;font-size:9pt;color:#6b7a90}
h1{font-size:20pt;line-height:1.3;margin:4px 0 0}
article>h1{display:none}
h2{font-size:13pt;margin:18px 0 6px;break-after:avoid}
p{margin:0 0 9px}
ul,ol{margin:0 0 10px;padding-left:20px}
li{margin-bottom:4px}
.quiz-list>li{margin-bottom:14px;break-inside:avoid}
.quiz-list li.correct{font-weight:600;color:#127a55}
.quiz-list li.correct:after{content:" ✓"}
.muted{color:#6b7a90;font-size:10pt}
.flashcards{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.flashcard{border:1px solid #d9e2ef;border-radius:8px;padding:10px 12px;break-inside:avoid;display:flex;flex-direction:column;gap:6px}
.flashcard span{color:#4d5d73}
.slide-preview{border:1px solid #d9e2ef;border-radius:8px;padding:12px 14px;margin-bottom:10px;break-inside:avoid}
.slide-preview small{font-size:8pt;letter-spacing:.08em;text-transform:uppercase;color:#8a97a8}
.slide-preview h2{margin-top:2px}
footer{margin-top:24px;padding-top:8px;border-top:1px solid #e3e9f2;font-size:8.5pt;color:#8a97a8}
`